@proagentstore/cli 0.4.88 → 0.4.90
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 +45 -8
- package/dist/browser-runner/local-apply/confirmation.js +125 -0
- package/dist/browser-runner/local-apply/contract.js +38 -17
- package/dist/browser-runner/local-apply/runtime.js +121 -6
- package/dist/browser-runner/local-artifact/contract.js +24 -2
- package/dist/browser-runner/local-artifact/engine.js +64 -18
- package/dist/browser-runner/local-artifact/runtime.js +15 -6
- package/dist/browser-runner/server.js +4 -0
- package/package.json +1 -1
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
* The trace carries classes, domains, decisions and artifact handles — never a typed value.
|
|
26
26
|
*/
|
|
27
27
|
import { classifyApplyClick } from "./contract.js";
|
|
28
|
+
import { CONFIRMATION_INTERVAL_SECONDS, CONFIRMATION_LOOKS, CONFIRMATION_TEXT_RE, confirmationMarker, observe, } from "./confirmation.js";
|
|
28
29
|
import { ELEMENT_PROBE_FN } from "../commit-guard.js";
|
|
29
30
|
import { normalize } from "../local-artifact/engine.js";
|
|
30
31
|
import { evaluateResult, hostOf, refRole } from "../local-browser/bridge.js";
|
|
@@ -58,8 +59,15 @@ const CONSENT_NAME = /\b(agree|consent|terms|privacy|acknowledge|certify|accept|
|
|
|
58
59
|
const CREDENTIAL_NAME = /password|passcode|one[- ]?time|\botp\b|verification code|security code|2fa|two[- ]factor/i;
|
|
59
60
|
/** Keys that move between fields without entering or activating anything. */
|
|
60
61
|
const SAFE_KEYS = /^(Tab|Shift\+Tab|Arrow(Up|Down|Left|Right)|Escape|Home|End|PageUp|PageDown)$/;
|
|
61
|
-
/**
|
|
62
|
+
/**
|
|
63
|
+
* Runs inside the page, through the browser's own evaluate tool — never offered to the CLI.
|
|
64
|
+
*
|
|
65
|
+
* The success vocabulary is INTERPOLATED from `confirmation.ts` rather than written out here
|
|
66
|
+
* (#994): a second copy of that regex is how the "sent" family — the exact word SEEK uses — was
|
|
67
|
+
* missing from the detector while being present in nobody's tests, through three live runs.
|
|
68
|
+
*/
|
|
62
69
|
const INSPECT_PAGE = `() => {
|
|
70
|
+
const CONFIRMATION_TEXT_SOURCE = ${JSON.stringify(CONFIRMATION_TEXT_RE.source)};
|
|
63
71
|
const text = (document.body ? document.body.innerText : "").slice(0, 20000).toLowerCase();
|
|
64
72
|
const has = (s) => !!document.querySelector(s);
|
|
65
73
|
return {
|
|
@@ -70,7 +78,7 @@ const INSPECT_PAGE = `() => {
|
|
|
70
78
|
login: has('input[type=password]'),
|
|
71
79
|
duplicate: /you('ve| have) already applied|already applied (for|to) this|application (already exists|has already been (submitted|received))|duplicate application/.test(text),
|
|
72
80
|
antiBot: /unusual traffic|access (is )?denied|request (has been )?blocked|bot detected|checking (if the site connection is secure|your browser)/.test(text),
|
|
73
|
-
confirmed:
|
|
81
|
+
confirmed: new RegExp(CONFIRMATION_TEXT_SOURCE).test(text),
|
|
74
82
|
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)
|
|
75
83
|
? (/expired/.test(text) ? "expired" : "unavailable")
|
|
76
84
|
: null,
|
|
@@ -120,6 +128,8 @@ export class ApplyBridge {
|
|
|
120
128
|
/** Present only after the CLI explicitly reports a notice that the runner independently saw. */
|
|
121
129
|
unavailable = null;
|
|
122
130
|
submitAttempted = false;
|
|
131
|
+
/** What was seen after the submit click (#994) — read by the runtime to build the diagnostic. */
|
|
132
|
+
confirmation = null;
|
|
123
133
|
submitted = null;
|
|
124
134
|
/** The last thing that stopped the work and was not resolved — the block reason if the run ends here. */
|
|
125
135
|
blocked = null;
|
|
@@ -609,17 +619,44 @@ export class ApplyBridge {
|
|
|
609
619
|
this.submitAttempted = true;
|
|
610
620
|
this.host.emit({ type: "submit.attempted", url: before.url, domain, detail: { class: "submit", gateId: this.host.gateId } });
|
|
611
621
|
const res = await this.browser.callTool("browser_click", forward);
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
622
|
+
// Look REPEATEDLY, not once (#994). The old path waited two seconds and read the page a
|
|
623
|
+
// single time, so a receipt that arrives on a navigation or a client-side re-render a moment
|
|
624
|
+
// later was simply not there yet when the only look happened. `submitAttempted` is already
|
|
625
|
+
// true above, so waiting longer cannot make a pressed button look unpressed.
|
|
626
|
+
const { after, looks } = res.isError ? { after: null, looks: 0 } : await this.awaitConfirmation(before);
|
|
627
|
+
const seen = observe(before, after, looks);
|
|
628
|
+
this.confirmation = seen;
|
|
629
|
+
const evidence = { looks: seen.looks, urlChanged: seen.urlChanged, titleChanged: seen.titleChanged };
|
|
630
|
+
if (seen.marker) {
|
|
631
|
+
const at = after;
|
|
632
|
+
this.submitted = { url: at.url, at: new Date(this.host.now()).toISOString(), gateId: this.host.gateId };
|
|
633
|
+
this.host.emit({ type: "submit.confirmed", url: at.url, domain: hostOf(at.url) ?? domain, detail: { gateId: this.host.gateId, marker: seen.marker, ...evidence } });
|
|
617
634
|
return text("Submitted, and the site confirmed it. Stop now.");
|
|
618
635
|
}
|
|
619
636
|
this.blocked = { reason: "submit_unconfirmed", questions: ["The application was submitted but the site did not confirm it. Check the employer's site or your email before anything is retried."] };
|
|
620
|
-
this.host.emit({ type: "submit.unconfirmed", ...(after ? { url: after.url } : {}), domain, detail: { gateId: this.host.gateId } });
|
|
637
|
+
this.host.emit({ type: "submit.unconfirmed", ...(after ? { url: after.url } : {}), domain, detail: { gateId: this.host.gateId, ...evidence } });
|
|
621
638
|
return text("The submit was pressed but no confirmation was seen. Do not press it again. Stop now.", true);
|
|
622
639
|
}
|
|
640
|
+
/**
|
|
641
|
+
* Read the page until it confirms, or until the looks run out (#994).
|
|
642
|
+
*
|
|
643
|
+
* It returns the LAST page it read, not the first: when nothing confirms, the owner's
|
|
644
|
+
* diagnostic should describe where the browser actually ended up. Bounded by
|
|
645
|
+
* `CONFIRMATION_LOOKS` — an application run holds the owner's only browser, so "look again"
|
|
646
|
+
* must never become "sit here".
|
|
647
|
+
*/
|
|
648
|
+
async awaitConfirmation(before) {
|
|
649
|
+
let after = null;
|
|
650
|
+
for (let i = 0; i < CONFIRMATION_LOOKS; i++) {
|
|
651
|
+
await this.browser.callTool("browser_wait_for", { time: CONFIRMATION_INTERVAL_SECONDS }).catch(() => undefined);
|
|
652
|
+
const page = await this.inspect();
|
|
653
|
+
if (page)
|
|
654
|
+
after = page;
|
|
655
|
+
if (confirmationMarker(before, page))
|
|
656
|
+
return { after: page, looks: i + 1 };
|
|
657
|
+
}
|
|
658
|
+
return { after, looks: CONFIRMATION_LOOKS };
|
|
659
|
+
}
|
|
623
660
|
/** A cloud-persisted checkpoint, not a prompt instruction, authorizes each page's writes. */
|
|
624
661
|
requireSupervisor(tool, actionClass) {
|
|
625
662
|
if (this.supervisorApproved)
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Did the employer's site confirm the submission? (#994)
|
|
3
|
+
*
|
|
4
|
+
* ── The live failure this module exists for
|
|
5
|
+
*
|
|
6
|
+
* Three distinct approved auto-submit runs on `au.seek.com` reached `submit.attempted` and all
|
|
7
|
+
* three ended terminal `blocked / submit_unconfirmed` with `submittedAt=null`: applications
|
|
8
|
+
* `435d31c8…` (run `a4bf5c0f…`), `71765edd…` (run `76416747…`) and `b6244557…` (run `2f2e88a5…`).
|
|
9
|
+
* The honest safety state was right — nothing was retried, and the owner was told to check the
|
|
10
|
+
* employer's site — but a 3/3 rate says the DETECTOR was wrong, not the sites.
|
|
11
|
+
*
|
|
12
|
+
* Three facts about the old detector, each read off the code rather than guessed at:
|
|
13
|
+
*
|
|
14
|
+
* 1. **The vocabulary had no "sent".** It matched `thank you for (your) appl`,
|
|
15
|
+
* `application (has been|was) (received|submitted)`, `we('ve| have) received your application`
|
|
16
|
+
* and `successfully (applied|submitted)` — and nothing else. SEEK's own post-apply wording is
|
|
17
|
+
* the "sent" family ("Application sent", "Your application has been sent", "You've applied"),
|
|
18
|
+
* so the one word the page actually uses was the one word the regex could not see.
|
|
19
|
+
* 2. **It looked once, 2 seconds after the click** (`browser_wait_for {time: 2}` then one
|
|
20
|
+
* `inspect()`), so a success state that arrives on a navigation or a client-side re-render a
|
|
21
|
+
* moment later was simply not there yet when the only look happened.
|
|
22
|
+
* 3. **Text was the only evidence considered.** The URL the click navigates to, the title, and
|
|
23
|
+
* the site's own "you have already applied" notice were all collected by `INSPECT_PAGE` and
|
|
24
|
+
* none of them counted.
|
|
25
|
+
*
|
|
26
|
+
* What remains UNVERIFIED, and is stated here rather than implied: no live SEEK page was captured
|
|
27
|
+
* while writing this, so which of the three was decisive for those three runs is not established.
|
|
28
|
+
* The tests below use realistic fixtures of the wording SEEK and the big ATSs publish, which the
|
|
29
|
+
* issue explicitly allows; a live capture would still be worth taking.
|
|
30
|
+
*
|
|
31
|
+
* ── What counts as confirmation
|
|
32
|
+
*
|
|
33
|
+
* A named marker, never a feeling. Every marker is an id this platform defines, and only the ID
|
|
34
|
+
* crosses the contract — never the text that matched it. That is the same rule `signalsOf` follows
|
|
35
|
+
* in `runtime.ts` for engine output, and for the same reason: an application page holds the
|
|
36
|
+
* owner's typed answers, and no redaction reliably separates those from the rest of the prose.
|
|
37
|
+
*
|
|
38
|
+
* The URL and duplicate markers are deliberately comparative — they need the page as it was BEFORE
|
|
39
|
+
* the click, which the submit path already has. A `/success` URL that was already there is not
|
|
40
|
+
* evidence of this click; "you have already applied" that was already there is somebody's earlier
|
|
41
|
+
* application, not this one.
|
|
42
|
+
*/
|
|
43
|
+
/**
|
|
44
|
+
* The success vocabulary, as ONE source used in two places: this module (tested in Node) and the
|
|
45
|
+
* in-page script (built from `.source`, executed in the browser). Two copies of a regex is how the
|
|
46
|
+
* "sent" family went missing from one of them for three live runs.
|
|
47
|
+
*
|
|
48
|
+
* Lower-cased text is matched, so the patterns carry no case class.
|
|
49
|
+
*/
|
|
50
|
+
export const CONFIRMATION_TEXT_RE = /thank you for (your )?appl|application (has been |was )?(received|submitted|sent)|we('ve| have) received your application|successfully (applied|submitted|sent)|your application (is|has been) (on its way|complete)|you(r application)?('ve| have)? (now )?applied|applied (on|to this job)|application (sent|complete|submitted) *[.!]?$/;
|
|
51
|
+
/**
|
|
52
|
+
* A URL that is itself the receipt. Matched against the path and query of the page the click
|
|
53
|
+
* landed on, and only when the click CHANGED it.
|
|
54
|
+
*
|
|
55
|
+
* Kept narrow on purpose: `/apply` alone is the form, not the confirmation, and treating it as one
|
|
56
|
+
* would turn "the click did nothing" into "submitted".
|
|
57
|
+
*/
|
|
58
|
+
export const CONFIRMATION_URL_RE = /(^|\/)(apply|application|applications|job)?[^/]*\/(success|succeeded|complete|completed|confirmation|confirmed|submitted|sent|thank-?you|receipt)(\/|$|\?)|[?&](applied|submitted|success)=(1|true|yes)/;
|
|
59
|
+
/** Ordered by how strong the evidence is: the first match is the one recorded. */
|
|
60
|
+
export function confirmationMarker(before, after) {
|
|
61
|
+
if (!after)
|
|
62
|
+
return null;
|
|
63
|
+
if (after.confirmed)
|
|
64
|
+
return "page_text";
|
|
65
|
+
const url = urlPathAndQuery(after.url).toLowerCase();
|
|
66
|
+
if (CONFIRMATION_URL_RE.test(url) && before && urlPathAndQuery(before.url).toLowerCase() !== url)
|
|
67
|
+
return "url_receipt";
|
|
68
|
+
// The site's own duplicate notice, but ONLY if it appeared after this click: "you have already
|
|
69
|
+
// applied" that was on the page beforehand is an earlier application of the owner's, and
|
|
70
|
+
// reading it as this run's success would record a submission that never happened.
|
|
71
|
+
if (after.duplicate && before && !before.duplicate)
|
|
72
|
+
return "already_applied_notice";
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
/** Path + query only: a host or a fragment is not evidence, and the host is recorded separately. */
|
|
76
|
+
function urlPathAndQuery(raw) {
|
|
77
|
+
try {
|
|
78
|
+
const u = new URL(raw);
|
|
79
|
+
return `${u.pathname}${u.search}`;
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
return raw;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* How long to keep looking, and how often.
|
|
87
|
+
*
|
|
88
|
+
* The old path waited 2s once. These are bounded for the reason every wait in this runner is: an
|
|
89
|
+
* application run holds the owner's only browser, so "look again" must not become "sit here". Six
|
|
90
|
+
* looks over ~9s covers a navigation and a client-side re-render, and stops long before the
|
|
91
|
+
* engine's own minute budget notices.
|
|
92
|
+
*/
|
|
93
|
+
export const CONFIRMATION_LOOKS = 6;
|
|
94
|
+
export const CONFIRMATION_INTERVAL_SECONDS = 1.5;
|
|
95
|
+
export function observe(before, after, looks) {
|
|
96
|
+
return {
|
|
97
|
+
marker: confirmationMarker(before, after),
|
|
98
|
+
looks,
|
|
99
|
+
urlChanged: !!after && !!before && after.url !== before.url,
|
|
100
|
+
titleChanged: !!after && !!before && after.title !== before.title,
|
|
101
|
+
unreadable: !after,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* The closed-vocabulary signals that go on the run's diagnostic when a submit was pressed and not
|
|
106
|
+
* confirmed, so the owner's record says what was seen rather than only what was not.
|
|
107
|
+
*
|
|
108
|
+
* Each is an id, so nothing here can carry page text; together they distinguish the three cases a
|
|
109
|
+
* person would act on differently: the page never changed (the click may not have landed), the page
|
|
110
|
+
* moved but said nothing this detector knows (a wording to add), and the page could not be read.
|
|
111
|
+
*/
|
|
112
|
+
export function confirmationSignals(o) {
|
|
113
|
+
const out = [];
|
|
114
|
+
if (o.unreadable)
|
|
115
|
+
out.push("confirmation_page_unreadable");
|
|
116
|
+
else if (!o.marker)
|
|
117
|
+
out.push("confirmation_no_marker");
|
|
118
|
+
if (o.urlChanged)
|
|
119
|
+
out.push("confirmation_url_changed");
|
|
120
|
+
else if (!o.unreadable)
|
|
121
|
+
out.push("confirmation_url_unchanged");
|
|
122
|
+
if (o.titleChanged)
|
|
123
|
+
out.push("confirmation_title_changed");
|
|
124
|
+
return out;
|
|
125
|
+
}
|
|
@@ -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
|
+
/** Read-only job-page check used before an email lead may enter material preparation. */
|
|
35
|
+
export const LOCAL_APPLY_PREFLIGHT_PATH = "/local-apply/preflight";
|
|
34
36
|
/** Delivers a cloud-persisted supervisory decision to one paused checkpoint. */
|
|
35
37
|
export const LOCAL_APPLY_DIRECTIVE_PATH = "/local-apply/directive";
|
|
36
38
|
export const LOCAL_APPLY_ENGINES = ["claude", "codex"];
|
|
@@ -82,31 +84,37 @@ export const LOCAL_APPLY_BLOCK_REASONS = [
|
|
|
82
84
|
* dispatch, naming the update, rather than silently producing the older behaviour (`apply.ts`
|
|
83
85
|
* `runnerContractProblem`).
|
|
84
86
|
*
|
|
85
|
-
* ── Why it moved to 0.4.
|
|
87
|
+
* ── Why it moved to 0.4.90 (#953)
|
|
86
88
|
*
|
|
87
|
-
* #
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
-
*
|
|
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.
|
|
89
|
+
* #953 adds the read-only `/local-apply/preflight` route used before an email lead can enter
|
|
90
|
+
* tailoring. That route lives in `packages/browser-runner`, which ships INSIDE the published CLI.
|
|
91
|
+
* A machine below this floor cannot make the live/expired/unverifiable observation, so it must
|
|
92
|
+
* not silently look equivalent to a current runner.
|
|
96
93
|
*
|
|
97
94
|
* 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
|
|
99
|
-
*
|
|
100
|
-
* nothing.
|
|
95
|
+
* So this floor moves with it: a machine that cannot observe a post-submit receipt is refused
|
|
96
|
+
* with a sentence that says so, rather than reporting an unhelpful `submit_unconfirmed` state.
|
|
101
97
|
*
|
|
102
98
|
* Bump this when the runner's half of the contract changes again, together with the CLI version
|
|
103
99
|
* that ships it — `policy.test.ts` pins the pair, so the floor cannot name a release that has not
|
|
104
100
|
* been published. `cliAtLeast` treats an unreported version as capable, which is the convention
|
|
105
101
|
* every MIN_CLI gate here follows.
|
|
106
102
|
*/
|
|
107
|
-
export const LOCAL_APPLY_CONTRACT_MIN_CLI = "0.4.
|
|
108
|
-
export const LOCAL_APPLY_DIAGNOSTIC_CAUSES = ["bridge_unused", "engine_exited_nonzero", "timed_out", "no_engine_output"];
|
|
109
|
-
export const
|
|
103
|
+
export const LOCAL_APPLY_CONTRACT_MIN_CLI = "0.4.90";
|
|
104
|
+
export const LOCAL_APPLY_DIAGNOSTIC_CAUSES = ["bridge_unused", "engine_exited_nonzero", "timed_out", "no_engine_output", "submit_unconfirmed"];
|
|
105
|
+
export const LOCAL_APPLY_CONFIRMATION_MARKERS = ["page_text", "url_receipt", "already_applied_notice"];
|
|
106
|
+
export const LOCAL_APPLY_SIGNALS = [
|
|
107
|
+
"approval_policy_blocked",
|
|
108
|
+
"bridge_tools_missing",
|
|
109
|
+
"auth_prompt",
|
|
110
|
+
"no_output",
|
|
111
|
+
"engine_refused_task",
|
|
112
|
+
"confirmation_no_marker",
|
|
113
|
+
"confirmation_page_unreadable",
|
|
114
|
+
"confirmation_url_changed",
|
|
115
|
+
"confirmation_url_unchanged",
|
|
116
|
+
"confirmation_title_changed",
|
|
117
|
+
];
|
|
110
118
|
export const LOCAL_APPLY_UNAVAILABLE_REASONS = ["expired", "unavailable"];
|
|
111
119
|
export const LOCAL_APPLY_EVENT_TYPES = [
|
|
112
120
|
"engine.auth_checked",
|
|
@@ -140,7 +148,11 @@ const isHttpUrl = (v) => /^https?:\/\/[^\s/]+/i.test(v);
|
|
|
140
148
|
// submit since #985, and this whitelist silently dropped it — so the one fact that tells "it
|
|
141
149
|
// refused the control that opens the form" from "it refused the real submit" never reached the
|
|
142
150
|
// cloud trace, which is where the live investigation needed it.
|
|
143
|
-
|
|
151
|
+
// `marker`, `looks`, `urlChanged` and `titleChanged` are #994's post-click evidence: a marker ID
|
|
152
|
+
// from `LOCAL_APPLY_CONFIRMATION_MARKERS`, how many times the page was read, and whether it moved.
|
|
153
|
+
// Each is an id, a count or a boolean — the same whitelist rule as everything else here, so no page
|
|
154
|
+
// text and no typed value can ride along on a `submit.confirmed` / `submit.unconfirmed` event.
|
|
155
|
+
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", "marker", "looks", "urlChanged", "titleChanged"]);
|
|
144
156
|
/** A validated event, or null. Detail keeps only whitelisted, primitive, bounded values. */
|
|
145
157
|
export function parseLocalApplyEvent(raw) {
|
|
146
158
|
if (!raw || typeof raw !== "object")
|
|
@@ -167,6 +179,15 @@ export function parseLocalApplyEvent(raw) {
|
|
|
167
179
|
for (const [k, v] of Object.entries(o.detail)) {
|
|
168
180
|
if (!DETAIL_KEYS.has(k))
|
|
169
181
|
continue;
|
|
182
|
+
// #994: `marker` is a runner-defined confirmation ID, never the success sentence the
|
|
183
|
+
// page displayed. Keep the closed vocabulary closed even if a compromised/outdated
|
|
184
|
+
// runner tries to use this otherwise-string field as a prose channel.
|
|
185
|
+
if (k === "marker") {
|
|
186
|
+
const marker = oneOf(LOCAL_APPLY_CONFIRMATION_MARKERS, v);
|
|
187
|
+
if (marker)
|
|
188
|
+
detail[k] = marker;
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
170
191
|
if (typeof v === "string")
|
|
171
192
|
detail[k] = v.slice(0, 300);
|
|
172
193
|
else if (typeof v === "number" || typeof v === "boolean")
|
|
@@ -15,12 +15,13 @@
|
|
|
15
15
|
* - the result: `awaiting_review`, `submitted` (only with the site's confirmation), `blocked`
|
|
16
16
|
* with the reason, or `failed`.
|
|
17
17
|
*
|
|
18
|
-
*
|
|
19
|
-
* "the runner lost this run".
|
|
18
|
+
* Active runs live in memory: a runner restart loses them, and `status` answers 404, which PAGS
|
|
19
|
+
* reads as "the runner lost this run". Terminal typed/redacted results are journaled locally so a
|
|
20
|
+
* restart after completion does not erase observed evidence before PAGS pulls it.
|
|
20
21
|
*/
|
|
21
22
|
import { spawn as nodeSpawn } from "node:child_process";
|
|
22
23
|
import { createHash, randomBytes } from "node:crypto";
|
|
23
|
-
import { existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
24
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
24
25
|
import { homedir } from "node:os";
|
|
25
26
|
import { join, resolve, sep } from "node:path";
|
|
26
27
|
import { fileURLToPath } from "node:url";
|
|
@@ -33,7 +34,8 @@ import { redactText, secretEnvValues } from "../local-browser/contract.js";
|
|
|
33
34
|
import { finalText, missingLogin, signInHelp } from "../local-browser/engine.js";
|
|
34
35
|
import { resolveWorkspacePath } from "../local-browser/runtime.js";
|
|
35
36
|
import { ApplyBridge } from "./bridge.js";
|
|
36
|
-
import {
|
|
37
|
+
import { confirmationSignals } from "./confirmation.js";
|
|
38
|
+
import { LOCAL_APPLY_AUTH_MODES, LOCAL_APPLY_CAPS, LOCAL_APPLY_ENGINES, LOCAL_APPLY_MODES, LOCAL_APPLY_TASK_TYPE, parseLocalApplyResult, } from "./contract.js";
|
|
37
39
|
import { applyPrompt, buildApplyEngineSpec, observedApplyAuth } from "./engine.js";
|
|
38
40
|
const MAX_EVENTS = 1000;
|
|
39
41
|
const OUTPUT_LINES = 400;
|
|
@@ -181,6 +183,104 @@ export class LocalApplyRuntime {
|
|
|
181
183
|
this.now = deps.now ?? Date.now;
|
|
182
184
|
this.spawn = deps.spawn ?? nodeSpawn;
|
|
183
185
|
this.retentionMs = deps.retentionMs ?? 24 * 60 * 60 * 1000;
|
|
186
|
+
this.hydrateTerminalJournals();
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* Read a specific job page before an email alert can cause any materials to be generated.
|
|
190
|
+
* This deliberately creates no application run, launches no CLI, and never clicks/fills: the
|
|
191
|
+
* only evidence it returns is a closed, redacted vocabulary the API can durably record.
|
|
192
|
+
*/
|
|
193
|
+
async preflight(raw) {
|
|
194
|
+
const input = raw && typeof raw === "object" ? raw : {};
|
|
195
|
+
const jobUrl = typeof input.jobUrl === "string" ? input.jobUrl.trim() : "";
|
|
196
|
+
let parsed;
|
|
197
|
+
try {
|
|
198
|
+
parsed = new URL(jobUrl);
|
|
199
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:")
|
|
200
|
+
throw new Error("scheme");
|
|
201
|
+
}
|
|
202
|
+
catch {
|
|
203
|
+
return { state: "unverifiable", jobUrl, reason: "navigation_failed" };
|
|
204
|
+
}
|
|
205
|
+
const profile = input.browserProfile === "default" ? "default" : "isolated";
|
|
206
|
+
const dir = join(this.root, "preflight", randomBytes(12).toString("hex"));
|
|
207
|
+
let browser = null;
|
|
208
|
+
try {
|
|
209
|
+
browser = await this.deps.browserFor(profile, dir);
|
|
210
|
+
const navigated = await browser.tools.callTool("browser_navigate", { url: parsed.toString() });
|
|
211
|
+
if (navigated.isError)
|
|
212
|
+
return { state: "unverifiable", jobUrl: parsed.toString(), reason: "navigation_failed" };
|
|
213
|
+
const snapshot = await browser.tools.callTool("browser_snapshot");
|
|
214
|
+
const page = (snapshot.content ?? []).map((part) => part.text ?? "").join("\n");
|
|
215
|
+
if (snapshot.isError || /captcha|verify (?:you(?:'| a)re|that you are) human|access denied|unusual traffic|checking (?:if )?your browser/i.test(page)) {
|
|
216
|
+
return { state: "unverifiable", jobUrl: parsed.toString(), reason: "access_blocked" };
|
|
217
|
+
}
|
|
218
|
+
if (/(?: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)/i.test(page)) {
|
|
219
|
+
return { state: "unavailable", jobUrl: parsed.toString(), reason: /expired/i.test(page) ? "expired" : "unavailable" };
|
|
220
|
+
}
|
|
221
|
+
// An accessible Apply control is an active path, unlike a generic careers/search URL.
|
|
222
|
+
if (/(?:button|link)\s+"[^"]{0,80}\bapply(?: now| for| here)?\b|\bapply (?:now|for (?:this )?(?:job|role|position))\b|\bstart application\b/i.test(page)) {
|
|
223
|
+
return { state: "live", jobUrl: parsed.toString(), applyUrl: parsed.toString(), evidence: "apply_control_present" };
|
|
224
|
+
}
|
|
225
|
+
return { state: "unverifiable", jobUrl: parsed.toString(), reason: "no_active_apply_path" };
|
|
226
|
+
}
|
|
227
|
+
catch {
|
|
228
|
+
return { state: "unverifiable", jobUrl: parsed.toString(), reason: "navigation_failed" };
|
|
229
|
+
}
|
|
230
|
+
finally {
|
|
231
|
+
await browser?.stop().catch(() => undefined);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
journalPath(dir) {
|
|
235
|
+
return join(dir, "terminal-result.json");
|
|
236
|
+
}
|
|
237
|
+
/** Write-before-return: a terminal result is durable before `status` can expose it. */
|
|
238
|
+
persistTerminal(run) {
|
|
239
|
+
if (!run.result || !run.endedAt)
|
|
240
|
+
return;
|
|
241
|
+
const journal = { version: 1, envelope: run.envelope, events: run.events, seq: run.seq, result: run.result, endedAt: run.endedAt };
|
|
242
|
+
const path = this.journalPath(run.dir);
|
|
243
|
+
const tmp = `${path}.${randomBytes(8).toString("hex")}.tmp`;
|
|
244
|
+
writeFileSync(tmp, JSON.stringify(journal), { mode: 0o600 });
|
|
245
|
+
renameSync(tmp, path);
|
|
246
|
+
}
|
|
247
|
+
/** Hydrate terminal-only records. Interrupted runs remain deliberately unrecoverable. */
|
|
248
|
+
hydrateTerminalJournals() {
|
|
249
|
+
if (!existsSync(this.root))
|
|
250
|
+
return;
|
|
251
|
+
for (const instance of readdirSync(this.root)) {
|
|
252
|
+
const instanceDir = join(this.root, instance);
|
|
253
|
+
let runIds;
|
|
254
|
+
try {
|
|
255
|
+
runIds = readdirSync(instanceDir);
|
|
256
|
+
}
|
|
257
|
+
catch {
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
260
|
+
for (const runId of runIds) {
|
|
261
|
+
const dir = join(instanceDir, runId);
|
|
262
|
+
const path = this.journalPath(dir);
|
|
263
|
+
if (!existsSync(path))
|
|
264
|
+
continue;
|
|
265
|
+
try {
|
|
266
|
+
const raw = JSON.parse(readFileSync(path, "utf8"));
|
|
267
|
+
if (raw.version !== 1 || !raw.envelope || !raw.result || !Array.isArray(raw.events) || typeof raw.seq !== "number" || typeof raw.endedAt !== "number")
|
|
268
|
+
continue;
|
|
269
|
+
const envelope = parseApplyEnvelope(raw.envelope);
|
|
270
|
+
const parsed = parseLocalApplyResult(raw.result);
|
|
271
|
+
if ("error" in parsed || parsed.result.runId !== envelope.runId)
|
|
272
|
+
continue;
|
|
273
|
+
const checked = parsed.result;
|
|
274
|
+
this.runs.set(envelope.runId, {
|
|
275
|
+
envelope, dir, token: "", state: "ended", events: raw.events, seq: raw.seq,
|
|
276
|
+
result: checked, allow: new Set(envelope.policy.allowDomains), grounding: [], secrets: [], activeSince: raw.endedAt,
|
|
277
|
+
activeMs: 0, waiters: [], output: [], engineAuth: checked.engineAuth, cancelled: false, timedOut: false,
|
|
278
|
+
checkpoints: new Map(), endedAt: raw.endedAt,
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
catch { /* a corrupt local cache is never an authority */ }
|
|
282
|
+
}
|
|
283
|
+
}
|
|
184
284
|
}
|
|
185
285
|
start(raw) {
|
|
186
286
|
const envelope = parseApplyEnvelope(raw);
|
|
@@ -372,8 +472,20 @@ export class LocalApplyRuntime {
|
|
|
372
472
|
return { outcome: "submitted", summary, submitted: b.submitted };
|
|
373
473
|
if (b?.unavailable)
|
|
374
474
|
return { outcome: "blocked", summary, blockReason: "job_unavailable", unavailable: b.unavailable };
|
|
375
|
-
|
|
376
|
-
|
|
475
|
+
// #994: a pressed submit nobody confirmed now SAYS what was seen. The outcome is unchanged —
|
|
476
|
+
// terminal, no retry, the owner told to check the employer's site — because that safety is
|
|
477
|
+
// correct; what was missing was any way to tell "the click never moved the page" from "the
|
|
478
|
+
// page moved and said something this platform does not recognise yet".
|
|
479
|
+
if (b?.submitAttempted) {
|
|
480
|
+
const d = this.diagnosticOf(run, code, "submit_unconfirmed");
|
|
481
|
+
return {
|
|
482
|
+
outcome: "blocked",
|
|
483
|
+
summary,
|
|
484
|
+
blockReason: "submit_unconfirmed",
|
|
485
|
+
questions: b.blocked?.questions ?? [],
|
|
486
|
+
diagnostic: { ...d, signals: [...d.signals, ...(b.confirmation ? confirmationSignals(b.confirmation) : [])] },
|
|
487
|
+
};
|
|
488
|
+
}
|
|
377
489
|
if (b?.reviewReady)
|
|
378
490
|
return { outcome: "awaiting_review", summary };
|
|
379
491
|
if (!b?.filled && missingLogin(e.engine, run.output.join("\n")))
|
|
@@ -601,6 +713,9 @@ export class LocalApplyRuntime {
|
|
|
601
713
|
// nothing here is text: counts, an exit code, and signal ids this platform defines.
|
|
602
714
|
...(r.diagnostic ? { diagnostic: r.diagnostic } : {}),
|
|
603
715
|
};
|
|
716
|
+
// This must happen before stopping the browser or exposing `ended`: a process restart in the
|
|
717
|
+
// narrow handoff window must not turn an observed result into `runner_lost` in the cloud.
|
|
718
|
+
this.persistTerminal(run);
|
|
604
719
|
if (run.child && run.child.exitCode === null)
|
|
605
720
|
this.kill(run);
|
|
606
721
|
void run.browser?.stop().catch(() => undefined);
|
|
@@ -32,6 +32,7 @@ export const LOCAL_ARTIFACT_BLOCK_REASONS = [
|
|
|
32
32
|
"workspace_unavailable",
|
|
33
33
|
"missing_information",
|
|
34
34
|
"uncertain_claim",
|
|
35
|
+
"invalid_cli_output",
|
|
35
36
|
"engine_not_signed_in",
|
|
36
37
|
"api_key_refused",
|
|
37
38
|
];
|
|
@@ -48,6 +49,7 @@ export const LOCAL_ARTIFACT_EVENT_TYPES = [
|
|
|
48
49
|
"note",
|
|
49
50
|
];
|
|
50
51
|
export const LOCAL_ARTIFACT_ENGINE_AUTHS = ["machine-login", "subscription", "api-key", "missing_login", "unknown"];
|
|
52
|
+
export const LOCAL_ARTIFACT_PARSE_ATTEMPTS = ["fenced_json", "balanced_object"];
|
|
51
53
|
export const LOCAL_ARTIFACT_CAPS = { questions: 20, questionChars: 300, sources: 6, sourceBytes: 256 * 1024 };
|
|
52
54
|
/** An id used as a folder name: no separators, no dots, nothing a path could be built out of. */
|
|
53
55
|
export const PATH_SEGMENT = /^[A-Za-z0-9_-]{1,100}$/;
|
|
@@ -88,6 +90,7 @@ export function parseLocalArtifactLead(raw) {
|
|
|
88
90
|
const leadUrl = str(o.leadUrl, 2000) ?? "";
|
|
89
91
|
if (leadUrl && !/^https?:\/\/[^\s/]+/i.test(leadUrl))
|
|
90
92
|
return { error: "the lead's URL is not an http(s) URL" };
|
|
93
|
+
const workKey = str(o.workKey, 600);
|
|
91
94
|
const requestedAt = str(o.requestedAt, 40);
|
|
92
95
|
if (!requestedAt || Number.isNaN(Date.parse(requestedAt)))
|
|
93
96
|
return { error: "the lead has no requestedAt time" };
|
|
@@ -101,9 +104,9 @@ export function parseLocalArtifactLead(raw) {
|
|
|
101
104
|
if (v)
|
|
102
105
|
lead[f] = v;
|
|
103
106
|
}
|
|
104
|
-
return { lead: { eventId, sourceInstanceId, leadId, leadUrl, lifecycleVersion, requestedAt, lead } };
|
|
107
|
+
return { lead: { eventId, sourceInstanceId, leadId, leadUrl, ...(workKey ? { workKey } : {}), lifecycleVersion, requestedAt, lead } };
|
|
105
108
|
}
|
|
106
|
-
const DETAIL_KEYS = new Set(["engine", "authMode", "engineAuth", "role", "path", "sha256", "bytes", "kind", "total", "unmatched", "reason", "exitCode", "count", "removed", "status"]);
|
|
109
|
+
const DETAIL_KEYS = new Set(["engine", "authMode", "engineAuth", "role", "path", "sha256", "bytes", "kind", "total", "unmatched", "reason", "exitCode", "count", "removed", "status", "validationError", "parseAttempts", "rawOutputChars", "runId", "attemptNumber"]);
|
|
107
110
|
/** A validated event, or null. Detail keeps only whitelisted, primitive, bounded values. */
|
|
108
111
|
export function parseLocalArtifactEvent(raw) {
|
|
109
112
|
if (!raw || typeof raw !== "object")
|
|
@@ -181,6 +184,25 @@ export function parseLocalArtifactResult(raw) {
|
|
|
181
184
|
.filter((q) => typeof q === "string" && q.trim() !== "")
|
|
182
185
|
.slice(0, LOCAL_ARTIFACT_CAPS.questions)
|
|
183
186
|
.map((q) => q.trim().slice(0, LOCAL_ARTIFACT_CAPS.questionChars));
|
|
187
|
+
const diagnostic = o.diagnostic && typeof o.diagnostic === "object" ? o.diagnostic : null;
|
|
188
|
+
const validationError = diagnostic?.validationError;
|
|
189
|
+
if (diagnostic && (validationError === "no_json_object" || validationError === "invalid_json" || validationError === "not_draft_object" || validationError === "incomplete_draft" || validationError === "unverified_claim")) {
|
|
190
|
+
const parseAttempts = [];
|
|
191
|
+
if (Array.isArray(diagnostic.parseAttempts)) {
|
|
192
|
+
for (let index = 0; index < diagnostic.parseAttempts.length && index < 8; index += 1) {
|
|
193
|
+
const parseAttempt = oneOf(LOCAL_ARTIFACT_PARSE_ATTEMPTS, diagnostic.parseAttempts[index]);
|
|
194
|
+
if (parseAttempt && !parseAttempts.includes(parseAttempt))
|
|
195
|
+
parseAttempts.push(parseAttempt);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
out.diagnostic = {
|
|
199
|
+
validationError,
|
|
200
|
+
parseAttempts,
|
|
201
|
+
rawOutputChars: typeof diagnostic.rawOutputChars === "number" ? Math.max(0, Math.min(diagnostic.rawOutputChars, 200_000)) : 0,
|
|
202
|
+
runId: str(diagnostic.runId, 100) ?? runId,
|
|
203
|
+
attemptNumber: typeof diagnostic.attemptNumber === "number" && Number.isInteger(diagnostic.attemptNumber) && diagnostic.attemptNumber > 0 ? diagnostic.attemptNumber : 1,
|
|
204
|
+
};
|
|
205
|
+
}
|
|
184
206
|
}
|
|
185
207
|
if (outcome === "failed")
|
|
186
208
|
out.error = str(o.error, 1000) ?? "The run failed without a reason.";
|
|
@@ -19,8 +19,8 @@
|
|
|
19
19
|
*/
|
|
20
20
|
import { mergeEnv } from "../coding/engine-env.js";
|
|
21
21
|
import { resolveEngineAuth } from "../coding/engine-auth.js";
|
|
22
|
-
import { finalText, missingLogin } from "../local-browser/engine.js";
|
|
23
|
-
export {
|
|
22
|
+
import { finalText as finalTextFromEngine, missingLogin } from "../local-browser/engine.js";
|
|
23
|
+
export { missingLogin };
|
|
24
24
|
const PROVIDER_KEYS = ["ANTHROPIC_API_KEY", "OPENAI_API_KEY"];
|
|
25
25
|
const SUBSCRIPTION_TOKEN = "CLAUDE_CODE_OAUTH_TOKEN";
|
|
26
26
|
/** The engine's environment: the machine's, never with a provider API key. */
|
|
@@ -84,19 +84,64 @@ export function tailorPrompt(lead, sources) {
|
|
|
84
84
|
'{"status":"ready","resume_markdown":"…","cover_letter_markdown":"…","claims":[{"text":"…","source":"resume","quote":"…"}]}',
|
|
85
85
|
].join("\n");
|
|
86
86
|
}
|
|
87
|
-
/**
|
|
88
|
-
|
|
89
|
-
const
|
|
90
|
-
const
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
87
|
+
/** JSON objects from fenced blocks or prose, while respecting braces inside JSON strings. */
|
|
88
|
+
function jsonCandidates(text) {
|
|
89
|
+
const candidates = [];
|
|
90
|
+
for (const match of text.matchAll(/```(?:json)?\s*([\s\S]*?)```/gi))
|
|
91
|
+
candidates.push({ source: "fenced_json", text: match[1].trim() });
|
|
92
|
+
for (let start = 0; start < text.length; start++) {
|
|
93
|
+
if (text[start] !== "{")
|
|
94
|
+
continue;
|
|
95
|
+
let quoted = false, escaped = false, depth = 0;
|
|
96
|
+
for (let end = start; end < text.length; end++) {
|
|
97
|
+
const ch = text[end];
|
|
98
|
+
if (quoted) {
|
|
99
|
+
if (escaped)
|
|
100
|
+
escaped = false;
|
|
101
|
+
else if (ch === "\\")
|
|
102
|
+
escaped = true;
|
|
103
|
+
else if (ch === '"')
|
|
104
|
+
quoted = false;
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
if (ch === '"')
|
|
108
|
+
quoted = true;
|
|
109
|
+
else if (ch === "{")
|
|
110
|
+
depth++;
|
|
111
|
+
else if (ch === "}" && --depth === 0) {
|
|
112
|
+
candidates.push({ source: "balanced_object", text: text.slice(start, end + 1) });
|
|
113
|
+
break;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
96
116
|
}
|
|
97
|
-
|
|
98
|
-
|
|
117
|
+
return candidates;
|
|
118
|
+
}
|
|
119
|
+
/** Prefer the structured closing message, but tolerate a CLI that printed a JSON object directly.
|
|
120
|
+
* Non-JSON output must stay empty so login and process-failure handling remains fail-closed. */
|
|
121
|
+
export function finalText(engine, lines) {
|
|
122
|
+
const structured = finalTextFromEngine(engine, lines);
|
|
123
|
+
if (structured)
|
|
124
|
+
return structured;
|
|
125
|
+
const raw = lines.join("\n");
|
|
126
|
+
return jsonCandidates(raw).length ? raw : "";
|
|
127
|
+
}
|
|
128
|
+
/** The CLI's answer, with a closed diagnostic instead of a generic parsing collapse. */
|
|
129
|
+
export function parseDraft(text) {
|
|
130
|
+
const candidates = jsonCandidates(text);
|
|
131
|
+
if (!candidates.length)
|
|
132
|
+
return { draft: null, validationError: "no_json_object", parseAttempts: ["fenced_json", "balanced_object"] };
|
|
133
|
+
let invalid = false;
|
|
134
|
+
for (const candidate of candidates) {
|
|
135
|
+
try {
|
|
136
|
+
const parsed = JSON.parse(candidate.text);
|
|
137
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed) && "status" in parsed)
|
|
138
|
+
return { draft: parsed, parseAttempts: [candidate.source] };
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
invalid = true;
|
|
142
|
+
}
|
|
99
143
|
}
|
|
144
|
+
return { draft: null, validationError: invalid ? "invalid_json" : "not_draft_object", parseAttempts: [...new Set(candidates.map((c) => c.source))] };
|
|
100
145
|
}
|
|
101
146
|
/** Case, whitespace, quote-style and dash-style insensitive — so a faithful quote is never failed on typography. */
|
|
102
147
|
export function normalize(s) {
|
|
@@ -117,18 +162,19 @@ const digits = (s) => s.replace(/\D/g, "");
|
|
|
117
162
|
* Check the CLI's draft against the sources. Questions name WHAT to confirm, bounded, and are
|
|
118
163
|
* returned only to the owner's own record — never to a trace.
|
|
119
164
|
*/
|
|
120
|
-
export function checkDraft(
|
|
165
|
+
export function checkDraft(parsed, sources) {
|
|
166
|
+
const draft = parsed.draft;
|
|
121
167
|
if (!draft)
|
|
122
|
-
return { ok: false, reason: "
|
|
168
|
+
return { ok: false, reason: "invalid_cli_output", questions: [`The CLI did not return a readable application JSON object (${parsed.validationError ?? "unknown"}). Retry tailoring after checking the CLI output format.`], claims: 0, unmatched: 0, validationError: parsed.validationError ?? "no_json_object", parseAttempts: parsed.parseAttempts };
|
|
123
169
|
if (draft.status === "needs_human") {
|
|
124
170
|
const qs = (Array.isArray(draft.questions) ? draft.questions : []).filter((q) => typeof q === "string" && q.trim() !== "").map((q) => q.trim());
|
|
125
|
-
return { ok: false, reason: "missing_information", questions: qs.length ? qs : ["The CLI needs more information but did not say what."], claims: 0, unmatched: 0 };
|
|
171
|
+
return { ok: false, reason: "missing_information", questions: qs.length ? qs : ["The CLI needs more information but did not say what."], claims: 0, unmatched: 0, validationError: "incomplete_draft", parseAttempts: parsed.parseAttempts };
|
|
126
172
|
}
|
|
127
173
|
const resume = typeof draft.resume_markdown === "string" ? draft.resume_markdown.trim() : "";
|
|
128
174
|
const coverLetter = typeof draft.cover_letter_markdown === "string" ? draft.cover_letter_markdown.trim() : "";
|
|
129
175
|
const claims = Array.isArray(draft.claims) ? draft.claims : [];
|
|
130
176
|
if (draft.status !== "ready" || !resume || !coverLetter || !claims.length) {
|
|
131
|
-
return { ok: false, reason: "
|
|
177
|
+
return { ok: false, reason: "invalid_cli_output", questions: ["The CLI JSON was incomplete (missing a document or its source citations). Run it again."], claims: claims.length, unmatched: 0, validationError: "incomplete_draft", parseAttempts: parsed.parseAttempts };
|
|
132
178
|
}
|
|
133
179
|
const byRole = new Map(sources.map((s) => [s.role, normalize(s.text)]));
|
|
134
180
|
const all = normalize(sources.map((s) => s.text).join("\n"));
|
|
@@ -161,6 +207,6 @@ export function checkDraft(draft, sources) {
|
|
|
161
207
|
if (digits(p).length >= 9 && !sourceDigits.includes(digits(p)))
|
|
162
208
|
flag("Confirm the phone number in the draft — it does not appear in your sources.");
|
|
163
209
|
if (unmatched)
|
|
164
|
-
return { ok: false, reason: "uncertain_claim", questions, claims: claims.length, unmatched };
|
|
210
|
+
return { ok: false, reason: "uncertain_claim", questions, claims: claims.length, unmatched, validationError: "unverified_claim", parseAttempts: parsed.parseAttempts };
|
|
165
211
|
return { ok: true, resume, coverLetter, claims: claims.length };
|
|
166
212
|
}
|
|
@@ -273,10 +273,19 @@ export class LocalArtifactRuntime {
|
|
|
273
273
|
return;
|
|
274
274
|
}
|
|
275
275
|
const check = checkDraft(parseDraft(text), ctx.texts);
|
|
276
|
-
|
|
276
|
+
const retry = /:retry:(\d+)$/.exec(e.requestId);
|
|
277
|
+
const attemptNumber = retry ? Number(retry[1]) : 1;
|
|
278
|
+
const diagnostic = check.ok ? undefined : {
|
|
279
|
+
validationError: check.validationError,
|
|
280
|
+
parseAttempts: check.parseAttempts,
|
|
281
|
+
rawOutputChars: text.length,
|
|
282
|
+
runId: e.runId,
|
|
283
|
+
attemptNumber,
|
|
284
|
+
};
|
|
285
|
+
this.emit(run, { type: "claims.checked", detail: { total: check.claims, unmatched: check.ok ? 0 : check.unmatched, ...(diagnostic ? { validationError: diagnostic.validationError, parseAttempts: diagnostic.parseAttempts.join(","), rawOutputChars: diagnostic.rawOutputChars, runId: diagnostic.runId, attemptNumber } : {}) } });
|
|
277
286
|
if (!check.ok) {
|
|
278
287
|
this.removeIfEmpty(ctx.outDir);
|
|
279
|
-
this.pauseEnd(run, check.reason, check.questions);
|
|
288
|
+
this.pauseEnd(run, check.reason, check.questions, diagnostic);
|
|
280
289
|
return;
|
|
281
290
|
}
|
|
282
291
|
const at = new Date(this.now()).toISOString();
|
|
@@ -340,9 +349,9 @@ export class LocalArtifactRuntime {
|
|
|
340
349
|
return;
|
|
341
350
|
run.events.push({ ...ev, at: new Date(this.now()).toISOString(), seq: ++run.seq });
|
|
342
351
|
}
|
|
343
|
-
pauseEnd(run, reason, questions) {
|
|
344
|
-
this.emit(run, { type: "run.needs_human", detail: { reason, count: questions.length } });
|
|
345
|
-
this.end(run, { outcome: "needs_human", blockReason: reason, questions });
|
|
352
|
+
pauseEnd(run, reason, questions, diagnostic) {
|
|
353
|
+
this.emit(run, { type: "run.needs_human", detail: { reason, count: questions.length, ...(diagnostic ? { validationError: diagnostic.validationError, parseAttempts: diagnostic.parseAttempts.join(","), rawOutputChars: diagnostic.rawOutputChars, runId: diagnostic.runId, attemptNumber: diagnostic.attemptNumber } : {}) } });
|
|
354
|
+
this.end(run, { outcome: "needs_human", blockReason: reason, questions, diagnostic });
|
|
346
355
|
}
|
|
347
356
|
end(run, r) {
|
|
348
357
|
if (run.state === "ended")
|
|
@@ -360,7 +369,7 @@ export class LocalArtifactRuntime {
|
|
|
360
369
|
engineAuth: run.engineAuth,
|
|
361
370
|
traceId: run.envelope.runId,
|
|
362
371
|
...(r.generatedAt ? { generatedAt: r.generatedAt } : {}),
|
|
363
|
-
...(r.outcome === "needs_human" ? { blockReason: r.blockReason, questions: (r.questions ?? []).map((q) => redactText(q, run.secrets)) } : {}),
|
|
372
|
+
...(r.outcome === "needs_human" ? { blockReason: r.blockReason, questions: (r.questions ?? []).map((q) => redactText(q, run.secrets)), ...(r.diagnostic ? { diagnostic: r.diagnostic } : {}) } : {}),
|
|
364
373
|
// CLI output becomes this text, and a CLI prints whatever it was given: redacted before it is kept.
|
|
365
374
|
...(r.outcome === "failed" ? { error: redactText((r.error ?? "The run failed without a reason.").slice(0, 1000), run.secrets) } : {}),
|
|
366
375
|
};
|
|
@@ -226,6 +226,10 @@ async function route(runner, req, res) {
|
|
|
226
226
|
return json(res, 200, runner.localArtifact.cancel(await readJson(req)));
|
|
227
227
|
}
|
|
228
228
|
// Local application execution (#957) — run, status, resume, cancel; PULL like research.
|
|
229
|
+
// The email-lead preflight is read-only and precedes Tailor material generation (#953).
|
|
230
|
+
if (req.method === "POST" && path === "/local-apply/preflight") {
|
|
231
|
+
return json(res, 200, await runner.localApply.preflight(await readJson(req)));
|
|
232
|
+
}
|
|
229
233
|
if (req.method === "POST" && path === "/local-apply/run") {
|
|
230
234
|
return json(res, 202, runner.localApply.start(await readJson(req)));
|
|
231
235
|
}
|