@proagentstore/cli 0.4.89 → 0.4.91
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/confirmation.js +1 -1
- package/dist/browser-runner/local-apply/contract.js +8 -7
- package/dist/browser-runner/local-apply/runtime.js +106 -4
- 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/dist/index.js +537 -219
- package/package.json +1 -1
|
@@ -63,7 +63,7 @@ export function confirmationMarker(before, after) {
|
|
|
63
63
|
if (after.confirmed)
|
|
64
64
|
return "page_text";
|
|
65
65
|
const url = urlPathAndQuery(after.url).toLowerCase();
|
|
66
|
-
if (CONFIRMATION_URL_RE.test(url) &&
|
|
66
|
+
if (CONFIRMATION_URL_RE.test(url) && before && urlPathAndQuery(before.url).toLowerCase() !== url)
|
|
67
67
|
return "url_receipt";
|
|
68
68
|
// The site's own duplicate notice, but ONLY if it appeared after this click: "you have already
|
|
69
69
|
// applied" that was on the page beforehand is an earlier application of the owner's, and
|
|
@@ -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,13 +84,12 @@ 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
|
-
* equivalent to a current runner.
|
|
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.
|
|
92
93
|
*
|
|
93
94
|
* A behaviour change in the runner is a CONTRACT change, because the cloud's decisions assume it.
|
|
94
95
|
* So this floor moves with it: a machine that cannot observe a post-submit receipt is refused
|
|
@@ -99,7 +100,7 @@ export const LOCAL_APPLY_BLOCK_REASONS = [
|
|
|
99
100
|
* been published. `cliAtLeast` treats an unreported version as capable, which is the convention
|
|
100
101
|
* every MIN_CLI gate here follows.
|
|
101
102
|
*/
|
|
102
|
-
export const LOCAL_APPLY_CONTRACT_MIN_CLI = "0.4.
|
|
103
|
+
export const LOCAL_APPLY_CONTRACT_MIN_CLI = "0.4.90";
|
|
103
104
|
export const LOCAL_APPLY_DIAGNOSTIC_CAUSES = ["bridge_unused", "engine_exited_nonzero", "timed_out", "no_engine_output", "submit_unconfirmed"];
|
|
104
105
|
export const LOCAL_APPLY_CONFIRMATION_MARKERS = ["page_text", "url_receipt", "already_applied_notice"];
|
|
105
106
|
export const LOCAL_APPLY_SIGNALS = [
|
|
@@ -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";
|
|
@@ -34,7 +35,7 @@ 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
37
|
import { confirmationSignals } from "./confirmation.js";
|
|
37
|
-
import { LOCAL_APPLY_AUTH_MODES, LOCAL_APPLY_CAPS, LOCAL_APPLY_ENGINES, LOCAL_APPLY_MODES, LOCAL_APPLY_TASK_TYPE, } from "./contract.js";
|
|
38
|
+
import { LOCAL_APPLY_AUTH_MODES, LOCAL_APPLY_CAPS, LOCAL_APPLY_ENGINES, LOCAL_APPLY_MODES, LOCAL_APPLY_TASK_TYPE, parseLocalApplyResult, } from "./contract.js";
|
|
38
39
|
import { applyPrompt, buildApplyEngineSpec, observedApplyAuth } from "./engine.js";
|
|
39
40
|
const MAX_EVENTS = 1000;
|
|
40
41
|
const OUTPUT_LINES = 400;
|
|
@@ -182,6 +183,104 @@ export class LocalApplyRuntime {
|
|
|
182
183
|
this.now = deps.now ?? Date.now;
|
|
183
184
|
this.spawn = deps.spawn ?? nodeSpawn;
|
|
184
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
|
+
}
|
|
185
284
|
}
|
|
186
285
|
start(raw) {
|
|
187
286
|
const envelope = parseApplyEnvelope(raw);
|
|
@@ -614,6 +713,9 @@ export class LocalApplyRuntime {
|
|
|
614
713
|
// nothing here is text: counts, an exit code, and signal ids this platform defines.
|
|
615
714
|
...(r.diagnostic ? { diagnostic: r.diagnostic } : {}),
|
|
616
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);
|
|
617
719
|
if (run.child && run.child.exitCode === null)
|
|
618
720
|
this.kill(run);
|
|
619
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
|
}
|
package/dist/index.js
CHANGED
|
@@ -1406,6 +1406,316 @@ function parseStatusLine(line) {
|
|
|
1406
1406
|
return status;
|
|
1407
1407
|
}
|
|
1408
1408
|
|
|
1409
|
+
// src/commands/runner/auto-update.ts
|
|
1410
|
+
import { existsSync as existsSync7, mkdirSync as mkdirSync6, readFileSync as readFileSync7, renameSync as renameSync2, writeFileSync as writeFileSync5 } from "fs";
|
|
1411
|
+
import { dirname as dirname3, join as join8 } from "path";
|
|
1412
|
+
|
|
1413
|
+
// src/runner-lock.ts
|
|
1414
|
+
import { createHash, randomUUID as randomUUID2 } from "crypto";
|
|
1415
|
+
import { execFileSync as execFileSync2 } from "child_process";
|
|
1416
|
+
import { closeSync, mkdirSync as mkdirSync5, openSync, readFileSync as readFileSync6, renameSync, unlinkSync as unlinkSync2, writeSync } from "fs";
|
|
1417
|
+
import { homedir as homedir3 } from "os";
|
|
1418
|
+
import { join as join7 } from "path";
|
|
1419
|
+
function configDir(env = process.env) {
|
|
1420
|
+
return env.PAGS_CONFIG_DIR?.trim() || join7(homedir3(), ".config", "proagentstore");
|
|
1421
|
+
}
|
|
1422
|
+
var accountKey = (account) => createHash("sha256").update(account.trim().toLowerCase()).digest("hex").slice(0, 12);
|
|
1423
|
+
var lockPath = (account, env = process.env) => join7(configDir(env), `up-${accountKey(account)}.lock`);
|
|
1424
|
+
function classifyHolder(facts) {
|
|
1425
|
+
const lock = facts.lock;
|
|
1426
|
+
if (!lock) return "unknown";
|
|
1427
|
+
if (lock.v !== 1) return "unknown";
|
|
1428
|
+
if (!lock.pid || !Number.isInteger(lock.pid)) return "unknown";
|
|
1429
|
+
if (!facts.pidAlive) return "dead";
|
|
1430
|
+
if (lock.pidStartedAt && facts.pidStartedAt && lock.pidStartedAt !== facts.pidStartedAt) return "dead";
|
|
1431
|
+
if (facts.healthRsid && facts.healthRsid === lock.rsid) return "live";
|
|
1432
|
+
return "unresponsive";
|
|
1433
|
+
}
|
|
1434
|
+
var isTakeable = (verdict) => verdict === "dead";
|
|
1435
|
+
function holderWhere(lock) {
|
|
1436
|
+
if (lock.launch === "tmux") return `tmux session ${lock.launchDetail || "?"}`;
|
|
1437
|
+
if (lock.launch === "service") return "a background service (launchd/systemd)";
|
|
1438
|
+
if (lock.launch === "headless") return "headless";
|
|
1439
|
+
return lock.launchDetail ? `terminal ${lock.launchDetail}` : "a terminal";
|
|
1440
|
+
}
|
|
1441
|
+
function holderMessage(lock, verdict) {
|
|
1442
|
+
const since = new Date(lock.startedAt).toLocaleString();
|
|
1443
|
+
const lines = [
|
|
1444
|
+
`A \`pags up\` is already running for this account on this machine.`,
|
|
1445
|
+
` pid ${lock.pid}, started ${since}, CLI ${lock.version || "?"}, in ${holderWhere(lock)}.`
|
|
1446
|
+
];
|
|
1447
|
+
if (verdict === "unresponsive") {
|
|
1448
|
+
lines.push(` It is NOT answering its health check \u2014 it may be asleep, stopped, or the machine may be overloaded.`);
|
|
1449
|
+
}
|
|
1450
|
+
if (verdict === "unknown") {
|
|
1451
|
+
lines.push(` Its lock file could not be read (a newer CLI may have written it), so it is assumed to be running.`);
|
|
1452
|
+
}
|
|
1453
|
+
lines.push("", " What you can do:");
|
|
1454
|
+
if (lock.launch === "tmux" && lock.launchDetail) lines.push(` tmux attach -t ${lock.launchDetail} \u2014 go to the one that is running`);
|
|
1455
|
+
lines.push(
|
|
1456
|
+
` pags status \u2014 what it is doing, and anything else running here`,
|
|
1457
|
+
lock.launch === "service" ? ` (this one is a service \u2014 stop its launchd/systemd unit rather than replacing it)` : ` pags up --replace \u2014 stop that one gracefully, then take over`
|
|
1458
|
+
);
|
|
1459
|
+
return lines;
|
|
1460
|
+
}
|
|
1461
|
+
function detectLaunch(env = process.env, isTty = Boolean(process.stdout.isTTY)) {
|
|
1462
|
+
if (env.PAGS_SERVICE === "1") return { launch: "service", detail: env.PAGS_SERVICE_NAME?.trim() || "" };
|
|
1463
|
+
if (env.TMUX) return { launch: "tmux", detail: env.TMUX_PANE?.trim() || env.TMUX.split(",")[0]?.split("/").pop() || "" };
|
|
1464
|
+
if (!isTty) return { launch: "headless", detail: "" };
|
|
1465
|
+
return { launch: "tty", detail: env.TERM_SESSION_ID?.trim() || env.WINDOWID?.trim() || "" };
|
|
1466
|
+
}
|
|
1467
|
+
function pidAlive(pid) {
|
|
1468
|
+
if (!pid || !Number.isInteger(pid) || pid <= 0) return false;
|
|
1469
|
+
try {
|
|
1470
|
+
process.kill(pid, 0);
|
|
1471
|
+
return true;
|
|
1472
|
+
} catch (e) {
|
|
1473
|
+
return e.code === "EPERM";
|
|
1474
|
+
}
|
|
1475
|
+
}
|
|
1476
|
+
function pidStartedAt(pid) {
|
|
1477
|
+
if (!pidAlive(pid)) return "";
|
|
1478
|
+
try {
|
|
1479
|
+
if (process.platform === "win32") {
|
|
1480
|
+
const out = execFileSync2("wmic", ["process", "where", `ProcessId=${pid}`, "get", "CreationDate", "/value"], { encoding: "utf-8", timeout: 4e3, stdio: ["ignore", "pipe", "ignore"] });
|
|
1481
|
+
return (out.match(/CreationDate=(\S+)/)?.[1] ?? "").trim();
|
|
1482
|
+
}
|
|
1483
|
+
return execFileSync2("ps", ["-o", "lstart=", "-p", String(pid)], { encoding: "utf-8", timeout: 4e3, stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
1484
|
+
} catch {
|
|
1485
|
+
return "";
|
|
1486
|
+
}
|
|
1487
|
+
}
|
|
1488
|
+
async function healthRsid(port, token = "", timeoutMs = 5e3, fetchImpl = fetch) {
|
|
1489
|
+
if (!port) return null;
|
|
1490
|
+
try {
|
|
1491
|
+
const res = await fetchImpl(`http://127.0.0.1:${port}/health`, { headers: token ? { Authorization: `Bearer ${token}` } : {}, signal: AbortSignal.timeout(timeoutMs) });
|
|
1492
|
+
if (!res.ok) return null;
|
|
1493
|
+
const body = await res.json();
|
|
1494
|
+
return typeof body.rsid === "string" && body.rsid ? body.rsid : null;
|
|
1495
|
+
} catch {
|
|
1496
|
+
return null;
|
|
1497
|
+
}
|
|
1498
|
+
}
|
|
1499
|
+
function readLock(path) {
|
|
1500
|
+
try {
|
|
1501
|
+
const parsed = JSON.parse(readFileSync6(path, "utf-8"));
|
|
1502
|
+
return parsed && typeof parsed === "object" && typeof parsed.rsid === "string" ? parsed : null;
|
|
1503
|
+
} catch {
|
|
1504
|
+
return null;
|
|
1505
|
+
}
|
|
1506
|
+
}
|
|
1507
|
+
async function inspectHolder(path, opts = {}) {
|
|
1508
|
+
const lock = readLock(path);
|
|
1509
|
+
if (!lock) return { lock: null, verdict: "unknown" };
|
|
1510
|
+
const alive = pidAlive(lock.pid);
|
|
1511
|
+
const facts = {
|
|
1512
|
+
lock,
|
|
1513
|
+
pidAlive: alive,
|
|
1514
|
+
pidStartedAt: alive ? pidStartedAt(lock.pid) : "",
|
|
1515
|
+
healthRsid: alive ? await healthRsid(lock.port, lock.runnerToken ?? "", opts.healthTimeoutMs ?? 5e3, opts.fetchImpl ?? fetch) : null
|
|
1516
|
+
};
|
|
1517
|
+
return { lock, verdict: classifyHolder(facts) };
|
|
1518
|
+
}
|
|
1519
|
+
async function acquireLock(input) {
|
|
1520
|
+
const env = input.env ?? process.env;
|
|
1521
|
+
const path = lockPath(input.account, env);
|
|
1522
|
+
const rsid = input.rsid ?? randomUUID2();
|
|
1523
|
+
const handoff = input.handoffFrom?.trim() || env.PAGS_LOCK_HANDOFF?.trim() || "";
|
|
1524
|
+
mkdirSync5(configDir(env), { recursive: true });
|
|
1525
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
1526
|
+
const written = tryWrite(path, buildLock(input, rsid, env));
|
|
1527
|
+
if (written) return { ok: true, lock: written, path, tookOver: attempt === 0 ? "none" : "stale" };
|
|
1528
|
+
const { lock: lock2, verdict: verdict2 } = await inspectHolder(path);
|
|
1529
|
+
if (handoff && lock2?.rsid === handoff) {
|
|
1530
|
+
renameAside(path, lock2.pid);
|
|
1531
|
+
const taken = tryWrite(path, buildLock(input, rsid, env));
|
|
1532
|
+
if (taken) return { ok: true, lock: taken, path, tookOver: "handoff" };
|
|
1533
|
+
continue;
|
|
1534
|
+
}
|
|
1535
|
+
if (!isTakeable(verdict2)) return { ok: false, lock: lock2, path, verdict: verdict2 };
|
|
1536
|
+
if (!takeAsideIfStill(path, lock2?.rsid ?? "", lock2?.pid ?? 0)) continue;
|
|
1537
|
+
}
|
|
1538
|
+
const { lock, verdict } = await inspectHolder(path);
|
|
1539
|
+
return { ok: false, lock, path, verdict };
|
|
1540
|
+
}
|
|
1541
|
+
function buildLock(input, rsid, env) {
|
|
1542
|
+
const { launch, detail } = detectLaunch(env);
|
|
1543
|
+
const now = Date.now();
|
|
1544
|
+
return {
|
|
1545
|
+
v: 1,
|
|
1546
|
+
rsid,
|
|
1547
|
+
pid: process.pid,
|
|
1548
|
+
pidStartedAt: pidStartedAt(process.pid),
|
|
1549
|
+
startedAt: now,
|
|
1550
|
+
port: input.port,
|
|
1551
|
+
node: input.node,
|
|
1552
|
+
machineId: input.machineId,
|
|
1553
|
+
account: input.account,
|
|
1554
|
+
version: input.version,
|
|
1555
|
+
launch,
|
|
1556
|
+
launchDetail: detail,
|
|
1557
|
+
childPids: [],
|
|
1558
|
+
nonce: randomUUID2(),
|
|
1559
|
+
runnerToken: input.runnerToken ?? "",
|
|
1560
|
+
heartbeatAt: now
|
|
1561
|
+
};
|
|
1562
|
+
}
|
|
1563
|
+
function tryWrite(path, lock) {
|
|
1564
|
+
let fd = null;
|
|
1565
|
+
try {
|
|
1566
|
+
fd = openSync(path, "wx", 384);
|
|
1567
|
+
writeSync(fd, JSON.stringify(lock, null, 2));
|
|
1568
|
+
return lock;
|
|
1569
|
+
} catch (e) {
|
|
1570
|
+
if (e.code === "EEXIST") return null;
|
|
1571
|
+
throw e;
|
|
1572
|
+
} finally {
|
|
1573
|
+
if (fd !== null) closeSync(fd);
|
|
1574
|
+
}
|
|
1575
|
+
}
|
|
1576
|
+
function renameAside(path, pid) {
|
|
1577
|
+
const aside = `${path}.stale-${pid || "unknown"}`;
|
|
1578
|
+
try {
|
|
1579
|
+
renameSync(path, aside);
|
|
1580
|
+
return aside;
|
|
1581
|
+
} catch {
|
|
1582
|
+
return null;
|
|
1583
|
+
}
|
|
1584
|
+
}
|
|
1585
|
+
function takeAsideIfStill(path, deadRsid, pid) {
|
|
1586
|
+
const aside = renameAside(path, pid);
|
|
1587
|
+
if (!aside) return false;
|
|
1588
|
+
const moved = readLock(aside);
|
|
1589
|
+
if (moved && deadRsid && moved.rsid !== deadRsid) {
|
|
1590
|
+
try {
|
|
1591
|
+
renameSync(aside, path);
|
|
1592
|
+
} catch {
|
|
1593
|
+
}
|
|
1594
|
+
return false;
|
|
1595
|
+
}
|
|
1596
|
+
return true;
|
|
1597
|
+
}
|
|
1598
|
+
function releaseLock(path, rsid) {
|
|
1599
|
+
const current = readLock(path);
|
|
1600
|
+
if (!current || current.rsid !== rsid) return false;
|
|
1601
|
+
try {
|
|
1602
|
+
unlinkSync2(path);
|
|
1603
|
+
return true;
|
|
1604
|
+
} catch {
|
|
1605
|
+
return false;
|
|
1606
|
+
}
|
|
1607
|
+
}
|
|
1608
|
+
|
|
1609
|
+
// src/commands/runner/auto-update.ts
|
|
1610
|
+
function autoUpdateStatusWire(status) {
|
|
1611
|
+
if (!status) return void 0;
|
|
1612
|
+
return {
|
|
1613
|
+
"waiting-for-idle": "waiting_for_idle",
|
|
1614
|
+
running: "installing",
|
|
1615
|
+
"verified-success": "verified_success",
|
|
1616
|
+
failure: "failed"
|
|
1617
|
+
}[status] ?? status;
|
|
1618
|
+
}
|
|
1619
|
+
var statuses = /* @__PURE__ */ new Set([
|
|
1620
|
+
"enabled",
|
|
1621
|
+
"checking",
|
|
1622
|
+
"waiting-for-idle",
|
|
1623
|
+
"running",
|
|
1624
|
+
"restarting",
|
|
1625
|
+
"verified-success",
|
|
1626
|
+
"offline",
|
|
1627
|
+
"unsupported",
|
|
1628
|
+
"failure"
|
|
1629
|
+
]);
|
|
1630
|
+
function parseStatus(value) {
|
|
1631
|
+
if (typeof value !== "string") return void 0;
|
|
1632
|
+
const readable = {
|
|
1633
|
+
waiting_for_idle: "waiting-for-idle",
|
|
1634
|
+
installing: "running",
|
|
1635
|
+
verified_success: "verified-success",
|
|
1636
|
+
failed: "failure"
|
|
1637
|
+
}[value] ?? value;
|
|
1638
|
+
return statuses.has(readable) ? readable : void 0;
|
|
1639
|
+
}
|
|
1640
|
+
function autoUpdatePolicyKey(machineId, env = process.env) {
|
|
1641
|
+
return `${env.PAGS_LOCK_ACCOUNT?.trim() || "default"}:${machineId.trim() || "unidentified"}`;
|
|
1642
|
+
}
|
|
1643
|
+
function autoUpdatePolicyPath(env = process.env) {
|
|
1644
|
+
return env.PAGS_AUTO_UPDATE_POLICY_FILE?.trim() || join8(configDir(env), "auto-update.json");
|
|
1645
|
+
}
|
|
1646
|
+
function parseAutoUpdatePolicy(value) {
|
|
1647
|
+
if (!value || typeof value !== "object") return null;
|
|
1648
|
+
const raw = value;
|
|
1649
|
+
const autoUpdate = raw.autoUpdate ?? raw.auto_update;
|
|
1650
|
+
if (typeof autoUpdate !== "boolean") return null;
|
|
1651
|
+
const status = parseStatus(raw.status ?? raw.autoUpdateStatus ?? raw.auto_update_status);
|
|
1652
|
+
const latestVersion = raw.latestVersion ?? raw.latest_version;
|
|
1653
|
+
const lastAttemptAt = raw.lastAttemptAt ?? raw.last_attempt_at;
|
|
1654
|
+
const reason = raw.reason;
|
|
1655
|
+
return {
|
|
1656
|
+
autoUpdate,
|
|
1657
|
+
...status ? { status } : {},
|
|
1658
|
+
...typeof latestVersion === "string" ? { latestVersion } : {},
|
|
1659
|
+
...typeof lastAttemptAt === "string" ? { lastAttemptAt } : {},
|
|
1660
|
+
...typeof reason === "string" ? { reason } : {}
|
|
1661
|
+
};
|
|
1662
|
+
}
|
|
1663
|
+
function policyFromResponse(value) {
|
|
1664
|
+
if (!value || typeof value !== "object") return null;
|
|
1665
|
+
const raw = value;
|
|
1666
|
+
const direct = raw.autoUpdatePolicy ?? raw.auto_update_policy ?? raw.policy;
|
|
1667
|
+
if (typeof direct === "boolean") {
|
|
1668
|
+
return parseAutoUpdatePolicy({
|
|
1669
|
+
autoUpdate: direct,
|
|
1670
|
+
status: raw.autoUpdateStatus ?? raw.auto_update_status,
|
|
1671
|
+
latestVersion: raw.latestVersion ?? raw.latest_version,
|
|
1672
|
+
lastAttemptAt: raw.lastAttemptAt ?? raw.last_attempt_at,
|
|
1673
|
+
reason: raw.reason
|
|
1674
|
+
});
|
|
1675
|
+
}
|
|
1676
|
+
return parseAutoUpdatePolicy(direct) ?? parseAutoUpdatePolicy(raw.machine && typeof raw.machine === "object" ? raw.machine.autoUpdatePolicy ?? raw.machine.auto_update_policy : void 0);
|
|
1677
|
+
}
|
|
1678
|
+
function loadAutoUpdatePolicy(key, env = process.env) {
|
|
1679
|
+
try {
|
|
1680
|
+
const data = JSON.parse(readFileSync7(autoUpdatePolicyPath(env), "utf8"));
|
|
1681
|
+
if (data.v !== 1 || !data.policies || typeof data.policies !== "object") return null;
|
|
1682
|
+
return parseAutoUpdatePolicy(data.policies[key]);
|
|
1683
|
+
} catch {
|
|
1684
|
+
return null;
|
|
1685
|
+
}
|
|
1686
|
+
}
|
|
1687
|
+
function saveAutoUpdatePolicy(key, policy, env = process.env) {
|
|
1688
|
+
const path = autoUpdatePolicyPath(env);
|
|
1689
|
+
try {
|
|
1690
|
+
let old = { v: 1, policies: {} };
|
|
1691
|
+
if (existsSync7(path)) {
|
|
1692
|
+
const parsed = JSON.parse(readFileSync7(path, "utf8"));
|
|
1693
|
+
if (parsed.v === 1 && parsed.policies && typeof parsed.policies === "object") old = parsed;
|
|
1694
|
+
}
|
|
1695
|
+
mkdirSync6(dirname3(path), { recursive: true });
|
|
1696
|
+
const next = { v: 1, policies: { ...old.policies, [key]: policy } };
|
|
1697
|
+
const temp = `${path}.${process.pid}.tmp`;
|
|
1698
|
+
writeFileSync5(temp, JSON.stringify(next, null, 2), { mode: 384 });
|
|
1699
|
+
renameSync2(temp, path);
|
|
1700
|
+
return true;
|
|
1701
|
+
} catch {
|
|
1702
|
+
return false;
|
|
1703
|
+
}
|
|
1704
|
+
}
|
|
1705
|
+
function nextAutoUpdateDelayMs(failures, random = Math.random) {
|
|
1706
|
+
const base = failures > 0 ? Math.min(60 * 6e4, 6e4 * 2 ** Math.min(failures - 1, 6)) : 6 * 60 * 6e4;
|
|
1707
|
+
const jitter = 0.85 + Math.max(0, Math.min(1, random())) * 0.3;
|
|
1708
|
+
return Math.round(base * jitter);
|
|
1709
|
+
}
|
|
1710
|
+
function policyScheduleAction(previous, next, wasAuthoritative) {
|
|
1711
|
+
if (!next.autoUpdate) return "cancel";
|
|
1712
|
+
if (!wasAuthoritative || !previous.autoUpdate) return "start";
|
|
1713
|
+
return "keep";
|
|
1714
|
+
}
|
|
1715
|
+
function mayAutomaticallyRestart(input) {
|
|
1716
|
+
return input.authoritative && input.enabled && input.workObserved && input.busy.length === 0;
|
|
1717
|
+
}
|
|
1718
|
+
|
|
1409
1719
|
// src/commands/runner/relay.ts
|
|
1410
1720
|
var MEMBERSHIP_SYNC_PATH = "/pags/membership/sync";
|
|
1411
1721
|
var CLI_CONTROL_PATHS = /* @__PURE__ */ new Set([MEMBERSHIP_SYNC_PATH, RUNNER_UPDATE_PATH]);
|
|
@@ -1415,13 +1725,44 @@ async function connectViaRelay(instanceIds, localUrl, runnerToken, opts, force =
|
|
|
1415
1725
|
if (!pagsToken) throw new Error("PAGS token required for WebSocket relay");
|
|
1416
1726
|
const runnerNode = hostname3();
|
|
1417
1727
|
const machine = loadMachineIdentity(runnerNode);
|
|
1728
|
+
const autoUpdateKey = machine.id ? autoUpdatePolicyKey(machine.id) : "";
|
|
1729
|
+
let autoUpdatePolicy = autoUpdateKey ? loadAutoUpdatePolicy(autoUpdateKey) ?? { autoUpdate: false } : { autoUpdate: false };
|
|
1730
|
+
let autoUpdatePolicyAuthoritative = false;
|
|
1731
|
+
let refreshingAutomaticPolicy = false;
|
|
1732
|
+
let scheduleAutomaticUpdate = () => void 0;
|
|
1733
|
+
const saveAutoUpdateStatus = (status, extra = {}) => {
|
|
1734
|
+
autoUpdatePolicy = {
|
|
1735
|
+
...autoUpdatePolicy,
|
|
1736
|
+
...extra,
|
|
1737
|
+
status,
|
|
1738
|
+
lastAttemptAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1739
|
+
};
|
|
1740
|
+
if (autoUpdateKey) saveAutoUpdatePolicy(autoUpdateKey, autoUpdatePolicy);
|
|
1741
|
+
};
|
|
1742
|
+
const acceptAutoUpdatePolicy = (response) => {
|
|
1743
|
+
if (!autoUpdateKey) return;
|
|
1744
|
+
const policy = policyFromResponse(response);
|
|
1745
|
+
if (!policy) return;
|
|
1746
|
+
const action = policyScheduleAction(autoUpdatePolicy, policy, autoUpdatePolicyAuthoritative);
|
|
1747
|
+
autoUpdatePolicy = { ...autoUpdatePolicy, ...policy };
|
|
1748
|
+
autoUpdatePolicyAuthoritative = true;
|
|
1749
|
+
if (autoUpdateKey) saveAutoUpdatePolicy(autoUpdateKey, autoUpdatePolicy);
|
|
1750
|
+
if (action === "cancel") {
|
|
1751
|
+
scheduleAutomaticUpdate(false, true);
|
|
1752
|
+
return;
|
|
1753
|
+
}
|
|
1754
|
+
if (refreshingAutomaticPolicy) return;
|
|
1755
|
+
if (action === "start") {
|
|
1756
|
+
scheduleAutomaticUpdate(true);
|
|
1757
|
+
}
|
|
1758
|
+
};
|
|
1418
1759
|
const capabilities = await requestRunner("GET", "/capabilities", { url: localUrl, token: runnerToken, instanceId: instanceIds[0] });
|
|
1419
1760
|
const caps = Array.isArray(capabilities.capabilities) ? capabilities.capabilities.filter((item) => typeof item === "string") : [];
|
|
1420
1761
|
const registered = /* @__PURE__ */ new Set();
|
|
1421
1762
|
let lastRegisterError = "";
|
|
1422
1763
|
const registerRuntime = async (id, forceClaim = force) => {
|
|
1423
1764
|
try {
|
|
1424
|
-
await requestPags("POST", `/v1/instances/${apiPathSegment(id)}/runtime`, opts, {
|
|
1765
|
+
const result = await requestPags("POST", `/v1/instances/${apiPathSegment(id)}/runtime`, opts, {
|
|
1425
1766
|
endpointUrl: localUrl,
|
|
1426
1767
|
token: runnerToken,
|
|
1427
1768
|
placement: "local",
|
|
@@ -1430,8 +1771,14 @@ async function connectViaRelay(instanceIds, localUrl, runnerToken, opts, force =
|
|
|
1430
1771
|
runnerNode,
|
|
1431
1772
|
machineId: machine.id,
|
|
1432
1773
|
machineNames: machine.names,
|
|
1433
|
-
force: forceClaim
|
|
1774
|
+
force: forceClaim,
|
|
1775
|
+
autoUpdateStatus: {
|
|
1776
|
+
status: autoUpdateStatusWire(autoUpdatePolicy.status),
|
|
1777
|
+
latestVersion: autoUpdatePolicy.latestVersion,
|
|
1778
|
+
error: autoUpdatePolicy.reason
|
|
1779
|
+
}
|
|
1434
1780
|
});
|
|
1781
|
+
acceptAutoUpdatePolicy(result);
|
|
1435
1782
|
registered.add(id);
|
|
1436
1783
|
return true;
|
|
1437
1784
|
} catch (e) {
|
|
@@ -1442,6 +1789,17 @@ async function connectViaRelay(instanceIds, localUrl, runnerToken, opts, force =
|
|
|
1442
1789
|
return false;
|
|
1443
1790
|
}
|
|
1444
1791
|
};
|
|
1792
|
+
const refreshAutomaticPolicy = async () => {
|
|
1793
|
+
const id = registered.values().next().value;
|
|
1794
|
+
if (!id) return false;
|
|
1795
|
+
autoUpdatePolicyAuthoritative = false;
|
|
1796
|
+
refreshingAutomaticPolicy = true;
|
|
1797
|
+
try {
|
|
1798
|
+
return await registerRuntime(id) && autoUpdatePolicyAuthoritative;
|
|
1799
|
+
} finally {
|
|
1800
|
+
refreshingAutomaticPolicy = false;
|
|
1801
|
+
}
|
|
1802
|
+
};
|
|
1445
1803
|
const attached = /* @__PURE__ */ new Map();
|
|
1446
1804
|
const processStartedAt = Date.now();
|
|
1447
1805
|
const starts24h = recordRunnerStart(processStartedAt);
|
|
@@ -1455,43 +1813,179 @@ async function connectViaRelay(instanceIds, localUrl, runnerToken, opts, force =
|
|
|
1455
1813
|
};
|
|
1456
1814
|
for (const id of instanceIds) await registerRuntime(id);
|
|
1457
1815
|
const updateFacts = async () => {
|
|
1458
|
-
const
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1816
|
+
const [sessionsResult, healthResult] = await Promise.allSettled([
|
|
1817
|
+
requestRunner("GET", "/coding/sessions", {
|
|
1818
|
+
url: localUrl,
|
|
1819
|
+
token: runnerToken,
|
|
1820
|
+
instanceId: instanceIds[0]
|
|
1821
|
+
}),
|
|
1822
|
+
requestRunner("GET", "/health", { url: localUrl, token: runnerToken, instanceId: instanceIds[0] })
|
|
1823
|
+
]);
|
|
1824
|
+
const observationsUnavailable = sessionsResult.status !== "fulfilled" || healthResult.status !== "fulfilled" || !healthResult.value.work;
|
|
1825
|
+
const sessions = sessionsResult.status === "fulfilled" ? sessionsResult.value : { sessions: [] };
|
|
1826
|
+
const health = healthResult.status === "fulfilled" ? healthResult.value : {};
|
|
1827
|
+
const activeCoding = (sessions.sessions ?? []).filter((s) => s.alive && s.runState && s.runState !== "idle").map((s) => s.sessionId);
|
|
1828
|
+
const localRuns = health.work?.localRuns ?? 0;
|
|
1829
|
+
const codingTurns = health.work?.codingTurns ?? 0;
|
|
1463
1830
|
return {
|
|
1464
1831
|
current: CLI_VERSION,
|
|
1465
1832
|
latest: await latestPublishedVersion(),
|
|
1466
1833
|
fromSource: runsFromSource(),
|
|
1467
1834
|
restarter: restarterFrom(process.env),
|
|
1468
|
-
|
|
1835
|
+
// A restart ends browser/application work too. Give these durable names to the same
|
|
1836
|
+
// planner so neither manual nor automatic update can mistake them for an idle machine.
|
|
1837
|
+
busy: observationsUnavailable ? ["runner-work-observation-unavailable"] : [...activeCoding, ...Array.from({ length: codingTurns }, (_v, i) => `coding-turn-${i + 1}`), ...Array.from({ length: localRuns }, (_v, i) => health.work?.detail?.[i] || `local-run-${i + 1}`)]
|
|
1469
1838
|
};
|
|
1470
1839
|
};
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1840
|
+
let updateInstallInFlight = false;
|
|
1841
|
+
let installedPendingRestart = null;
|
|
1842
|
+
let automaticRestartDraining = false;
|
|
1843
|
+
const installAndRestart = async (plan, automatic = false) => {
|
|
1844
|
+
if (updateInstallInFlight) return false;
|
|
1845
|
+
updateInstallInFlight = true;
|
|
1846
|
+
if (automatic && (!await refreshAutomaticPolicy() || !autoUpdatePolicy.autoUpdate)) {
|
|
1847
|
+
updateInstallInFlight = false;
|
|
1848
|
+
return false;
|
|
1849
|
+
}
|
|
1850
|
+
if (installedPendingRestart !== plan.latest) {
|
|
1851
|
+
writeLine(`Updating ${plan.current} \u2192 ${plan.latest} (runner_update)\u2026`);
|
|
1852
|
+
try {
|
|
1853
|
+
await installVersion(plan.latest);
|
|
1854
|
+
installedPendingRestart = plan.latest;
|
|
1855
|
+
} catch (error) {
|
|
1856
|
+
updateInstallInFlight = false;
|
|
1857
|
+
throw error;
|
|
1858
|
+
}
|
|
1859
|
+
}
|
|
1860
|
+
if (automatic) {
|
|
1861
|
+
automaticRestartDraining = true;
|
|
1862
|
+
await new Promise((resolve5) => setTimeout(resolve5, 500));
|
|
1863
|
+
if (!await refreshAutomaticPolicy() || !autoUpdatePolicy.autoUpdate) {
|
|
1864
|
+
automaticRestartDraining = false;
|
|
1865
|
+
updateInstallInFlight = false;
|
|
1866
|
+
return false;
|
|
1867
|
+
}
|
|
1868
|
+
}
|
|
1869
|
+
const finalFacts = await updateFacts();
|
|
1870
|
+
if (finalFacts.busy.length || automatic && !mayAutomaticallyRestart({ authoritative: autoUpdatePolicyAuthoritative, enabled: autoUpdatePolicy.autoUpdate, workObserved: !finalFacts.busy.includes("runner-work-observation-unavailable"), busy: finalFacts.busy })) {
|
|
1871
|
+
automaticRestartDraining = false;
|
|
1872
|
+
updateInstallInFlight = false;
|
|
1873
|
+
return false;
|
|
1874
|
+
}
|
|
1474
1875
|
writeLine(`Installed ${plan.latest} \u2014 restarting; every agent re-attaches on the way back up.`);
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
}, 500).unref();
|
|
1876
|
+
for (const id of [...attached.keys()]) detach(id);
|
|
1877
|
+
leaveForRestart(plan.restarter);
|
|
1878
|
+
return true;
|
|
1479
1879
|
};
|
|
1480
1880
|
let updateWaiting = false;
|
|
1481
|
-
const updateWhenIdle = () => {
|
|
1881
|
+
const updateWhenIdle = (automatic = false) => {
|
|
1482
1882
|
if (updateWaiting) return;
|
|
1483
1883
|
updateWaiting = true;
|
|
1484
1884
|
const until = Date.now() + 60 * 6e4;
|
|
1485
1885
|
const tick = () => setTimeout(async () => {
|
|
1886
|
+
if (automatic && (!await refreshAutomaticPolicy() || !autoUpdatePolicy.autoUpdate)) {
|
|
1887
|
+
saveAutoUpdateStatus("offline", { reason: "The owner policy could not be refreshed; automatic update is deferred." });
|
|
1888
|
+
updateWaiting = false;
|
|
1889
|
+
return;
|
|
1890
|
+
}
|
|
1486
1891
|
const plan = planRunnerUpdate(await updateFacts());
|
|
1487
1892
|
if (plan.action === "update") {
|
|
1488
|
-
|
|
1893
|
+
if (automatic && !autoUpdatePolicy.autoUpdate) {
|
|
1894
|
+
updateWaiting = false;
|
|
1895
|
+
return;
|
|
1896
|
+
}
|
|
1897
|
+
if (automatic) saveAutoUpdateStatus("running", { latestVersion: plan.latest, reason: void 0 });
|
|
1898
|
+
const installed = await installAndRestart(plan, automatic).catch((e) => {
|
|
1899
|
+
if (automatic) saveAutoUpdateStatus("failure", { reason: e instanceof Error ? e.message : String(e) });
|
|
1900
|
+
writeError(`runner_update: install failed \u2014 ${e instanceof Error ? e.message : String(e)}`);
|
|
1901
|
+
return false;
|
|
1902
|
+
});
|
|
1903
|
+
if (automatic && !installed && Date.now() < until) {
|
|
1904
|
+
saveAutoUpdateStatus("waiting-for-idle", { latestVersion: plan.latest });
|
|
1905
|
+
tick();
|
|
1906
|
+
} else updateWaiting = false;
|
|
1907
|
+
} else if (plan.action === "wait" && Date.now() < until) {
|
|
1908
|
+
if (automatic) saveAutoUpdateStatus("waiting-for-idle", { latestVersion: plan.latest, reason: void 0 });
|
|
1909
|
+
tick();
|
|
1910
|
+
} else {
|
|
1911
|
+
if (automatic && plan.action === "refused") saveAutoUpdateStatus("unsupported", { reason: plan.reason });
|
|
1489
1912
|
updateWaiting = false;
|
|
1490
|
-
}
|
|
1491
|
-
else updateWaiting = false;
|
|
1913
|
+
}
|
|
1492
1914
|
}, 15e3).unref();
|
|
1493
1915
|
tick();
|
|
1494
1916
|
};
|
|
1917
|
+
let automaticTimer = null;
|
|
1918
|
+
let automaticChecking = false;
|
|
1919
|
+
let automaticFailures = 0;
|
|
1920
|
+
const runAutomaticUpdateCheck = async () => {
|
|
1921
|
+
if (!autoUpdatePolicy.autoUpdate || !autoUpdatePolicyAuthoritative || automaticChecking || updateWaiting) return;
|
|
1922
|
+
automaticChecking = true;
|
|
1923
|
+
try {
|
|
1924
|
+
if (!await refreshAutomaticPolicy() || !autoUpdatePolicy.autoUpdate) {
|
|
1925
|
+
saveAutoUpdateStatus("offline", { reason: "The owner policy could not be refreshed; automatic update is deferred." });
|
|
1926
|
+
scheduleAutomaticUpdate();
|
|
1927
|
+
return;
|
|
1928
|
+
}
|
|
1929
|
+
saveAutoUpdateStatus("checking", { reason: void 0 });
|
|
1930
|
+
const facts = await updateFacts();
|
|
1931
|
+
if (!autoUpdatePolicy.autoUpdate || !autoUpdatePolicyAuthoritative) return;
|
|
1932
|
+
if (!facts.latest) {
|
|
1933
|
+
automaticFailures++;
|
|
1934
|
+
saveAutoUpdateStatus("failure", { reason: "Could not check the trusted npm registry for a newer CLI release." });
|
|
1935
|
+
scheduleAutomaticUpdate();
|
|
1936
|
+
return;
|
|
1937
|
+
}
|
|
1938
|
+
automaticFailures = 0;
|
|
1939
|
+
const plan = planRunnerUpdate(facts);
|
|
1940
|
+
if (plan.action === "up-to-date") {
|
|
1941
|
+
saveAutoUpdateStatus("verified-success", { latestVersion: facts.latest, reason: void 0 });
|
|
1942
|
+
scheduleAutomaticUpdate();
|
|
1943
|
+
return;
|
|
1944
|
+
}
|
|
1945
|
+
if (plan.action === "refused") {
|
|
1946
|
+
saveAutoUpdateStatus("unsupported", { latestVersion: facts.latest, reason: plan.reason });
|
|
1947
|
+
scheduleAutomaticUpdate();
|
|
1948
|
+
return;
|
|
1949
|
+
}
|
|
1950
|
+
if (plan.action === "wait") {
|
|
1951
|
+
saveAutoUpdateStatus("waiting-for-idle", { latestVersion: plan.latest, reason: void 0 });
|
|
1952
|
+
updateWhenIdle(true);
|
|
1953
|
+
return;
|
|
1954
|
+
}
|
|
1955
|
+
if (!autoUpdatePolicy.autoUpdate || !autoUpdatePolicyAuthoritative) return;
|
|
1956
|
+
saveAutoUpdateStatus("running", { latestVersion: plan.latest, reason: void 0 });
|
|
1957
|
+
try {
|
|
1958
|
+
const installed = await installAndRestart(plan, true);
|
|
1959
|
+
if (!installed) {
|
|
1960
|
+
saveAutoUpdateStatus("waiting-for-idle", { latestVersion: plan.latest });
|
|
1961
|
+
updateWhenIdle(true);
|
|
1962
|
+
return;
|
|
1963
|
+
}
|
|
1964
|
+
saveAutoUpdateStatus("restarting", { latestVersion: plan.latest, reason: void 0 });
|
|
1965
|
+
} catch (e) {
|
|
1966
|
+
automaticFailures++;
|
|
1967
|
+
saveAutoUpdateStatus("failure", { latestVersion: plan.latest, reason: e instanceof Error ? e.message : String(e) });
|
|
1968
|
+
scheduleAutomaticUpdate();
|
|
1969
|
+
}
|
|
1970
|
+
} finally {
|
|
1971
|
+
automaticChecking = false;
|
|
1972
|
+
}
|
|
1973
|
+
};
|
|
1974
|
+
scheduleAutomaticUpdate = (initial = false, cancel = false) => {
|
|
1975
|
+
if (cancel || !autoUpdatePolicy.autoUpdate || !autoUpdatePolicyAuthoritative) {
|
|
1976
|
+
if (automaticTimer) clearTimeout(automaticTimer);
|
|
1977
|
+
automaticTimer = null;
|
|
1978
|
+
return;
|
|
1979
|
+
}
|
|
1980
|
+
if (automaticTimer || updateWaiting) return;
|
|
1981
|
+
const delay = initial ? Math.round(5e3 + Math.random() * 25e3) : nextAutoUpdateDelayMs(automaticFailures);
|
|
1982
|
+
automaticTimer = setTimeout(() => {
|
|
1983
|
+
automaticTimer = null;
|
|
1984
|
+
void runAutomaticUpdateCheck();
|
|
1985
|
+
}, delay);
|
|
1986
|
+
automaticTimer.unref();
|
|
1987
|
+
};
|
|
1988
|
+
if (autoUpdatePolicyAuthoritative && autoUpdatePolicy.autoUpdate) scheduleAutomaticUpdate(true);
|
|
1495
1989
|
const answerUpdate = async (body) => {
|
|
1496
1990
|
const dryRun = body?.dryRun === true;
|
|
1497
1991
|
const plan = planRunnerUpdate(await updateFacts());
|
|
@@ -1501,7 +1995,7 @@ async function connectViaRelay(instanceIds, localUrl, runnerToken, opts, force =
|
|
|
1501
1995
|
return { status: 200, result: { ...plan, detail: "Restarts itself as soon as these engines finish their turns \u2014 no run is cut off." } };
|
|
1502
1996
|
}
|
|
1503
1997
|
try {
|
|
1504
|
-
await installAndRestart(plan);
|
|
1998
|
+
if (!await installAndRestart(plan)) return { status: 409, result: { error: "A runner update is already installing or restarting." } };
|
|
1505
1999
|
} catch (e) {
|
|
1506
2000
|
return { status: 500, result: { error: `npm could not install ${plan.latest}: ${e instanceof Error ? e.message : String(e)}` } };
|
|
1507
2001
|
}
|
|
@@ -1567,7 +2061,10 @@ async function connectViaRelay(instanceIds, localUrl, runnerToken, opts, force =
|
|
|
1567
2061
|
reportRegistration();
|
|
1568
2062
|
},
|
|
1569
2063
|
answerControl,
|
|
1570
|
-
runnerNode
|
|
2064
|
+
runnerNode,
|
|
2065
|
+
// The final automatic-update drain is an admission gate, not merely another poll:
|
|
2066
|
+
// command work starts only when the old runner is still permitted to serve it.
|
|
2067
|
+
() => !automaticRestartDraining
|
|
1571
2068
|
)
|
|
1572
2069
|
);
|
|
1573
2070
|
if (label) writeLine(`Attached agent: ${label}`);
|
|
@@ -1612,7 +2109,17 @@ async function connectViaRelay(instanceIds, localUrl, runnerToken, opts, force =
|
|
|
1612
2109
|
});
|
|
1613
2110
|
for (const id of [...attached.keys()]) {
|
|
1614
2111
|
try {
|
|
1615
|
-
await requestPags("POST", `/v1/instances/${apiPathSegment(id)}/runtime/heartbeat`, opts, {
|
|
2112
|
+
const response = await requestPags("POST", `/v1/instances/${apiPathSegment(id)}/runtime/heartbeat`, opts, {
|
|
2113
|
+
runnerNode,
|
|
2114
|
+
machineId: machine.id,
|
|
2115
|
+
resources,
|
|
2116
|
+
autoUpdateStatus: {
|
|
2117
|
+
status: autoUpdateStatusWire(autoUpdatePolicy.status),
|
|
2118
|
+
latestVersion: autoUpdatePolicy.latestVersion,
|
|
2119
|
+
error: autoUpdatePolicy.reason
|
|
2120
|
+
}
|
|
2121
|
+
});
|
|
2122
|
+
acceptAutoUpdatePolicy(response);
|
|
1616
2123
|
} catch (e) {
|
|
1617
2124
|
failure = e instanceof Error ? e.message : String(e);
|
|
1618
2125
|
}
|
|
@@ -1701,7 +2208,7 @@ async function readSessionResources(localUrl, runnerToken) {
|
|
|
1701
2208
|
}
|
|
1702
2209
|
var RTT_PROBE_PREFIX = "rtt:";
|
|
1703
2210
|
var RTT_ECHO_PREFIX = "rtt-echo:";
|
|
1704
|
-
function openRelaySocket(instanceId, wsBase, mintToken, localUrl, runnerToken, force = false, onConflict, onOpen, onControl, runnerNode = hostname3()) {
|
|
2211
|
+
function openRelaySocket(instanceId, wsBase, mintToken, localUrl, runnerToken, force = false, onConflict, onOpen, onControl, runnerNode = hostname3(), admitMutation = () => true) {
|
|
1705
2212
|
let backoffMs = 1e3;
|
|
1706
2213
|
let reconnecting = false;
|
|
1707
2214
|
let closed = false;
|
|
@@ -1769,6 +2276,13 @@ function openRelaySocket(instanceId, wsBase, mintToken, localUrl, runnerToken, f
|
|
|
1769
2276
|
return;
|
|
1770
2277
|
}
|
|
1771
2278
|
const method = (cmd.method || "POST").toUpperCase();
|
|
2279
|
+
if (method !== "GET" && method !== "HEAD" && !admitMutation()) {
|
|
2280
|
+
try {
|
|
2281
|
+
ws.send(JSON.stringify({ id: cmd.id, status: 503, result: { error: "Runner is draining for a safe automatic update; retry shortly." } }));
|
|
2282
|
+
} catch {
|
|
2283
|
+
}
|
|
2284
|
+
return;
|
|
2285
|
+
}
|
|
1772
2286
|
const hasBody = method !== "GET" && method !== "HEAD" && cmd.body !== void 0;
|
|
1773
2287
|
try {
|
|
1774
2288
|
const headers = {};
|
|
@@ -1862,202 +2376,6 @@ function openRelaySocket(instanceId, wsBase, mintToken, localUrl, runnerToken, f
|
|
|
1862
2376
|
};
|
|
1863
2377
|
}
|
|
1864
2378
|
|
|
1865
|
-
// src/runner-lock.ts
|
|
1866
|
-
import { createHash, randomUUID as randomUUID2 } from "crypto";
|
|
1867
|
-
import { execFileSync as execFileSync2 } from "child_process";
|
|
1868
|
-
import { closeSync, mkdirSync as mkdirSync5, openSync, readFileSync as readFileSync6, renameSync, unlinkSync as unlinkSync2, writeSync } from "fs";
|
|
1869
|
-
import { homedir as homedir3 } from "os";
|
|
1870
|
-
import { join as join7 } from "path";
|
|
1871
|
-
function configDir(env = process.env) {
|
|
1872
|
-
return env.PAGS_CONFIG_DIR?.trim() || join7(homedir3(), ".config", "proagentstore");
|
|
1873
|
-
}
|
|
1874
|
-
var accountKey = (account) => createHash("sha256").update(account.trim().toLowerCase()).digest("hex").slice(0, 12);
|
|
1875
|
-
var lockPath = (account, env = process.env) => join7(configDir(env), `up-${accountKey(account)}.lock`);
|
|
1876
|
-
function classifyHolder(facts) {
|
|
1877
|
-
const lock = facts.lock;
|
|
1878
|
-
if (!lock) return "unknown";
|
|
1879
|
-
if (lock.v !== 1) return "unknown";
|
|
1880
|
-
if (!lock.pid || !Number.isInteger(lock.pid)) return "unknown";
|
|
1881
|
-
if (!facts.pidAlive) return "dead";
|
|
1882
|
-
if (lock.pidStartedAt && facts.pidStartedAt && lock.pidStartedAt !== facts.pidStartedAt) return "dead";
|
|
1883
|
-
if (facts.healthRsid && facts.healthRsid === lock.rsid) return "live";
|
|
1884
|
-
return "unresponsive";
|
|
1885
|
-
}
|
|
1886
|
-
var isTakeable = (verdict) => verdict === "dead";
|
|
1887
|
-
function holderWhere(lock) {
|
|
1888
|
-
if (lock.launch === "tmux") return `tmux session ${lock.launchDetail || "?"}`;
|
|
1889
|
-
if (lock.launch === "service") return "a background service (launchd/systemd)";
|
|
1890
|
-
if (lock.launch === "headless") return "headless";
|
|
1891
|
-
return lock.launchDetail ? `terminal ${lock.launchDetail}` : "a terminal";
|
|
1892
|
-
}
|
|
1893
|
-
function holderMessage(lock, verdict) {
|
|
1894
|
-
const since = new Date(lock.startedAt).toLocaleString();
|
|
1895
|
-
const lines = [
|
|
1896
|
-
`A \`pags up\` is already running for this account on this machine.`,
|
|
1897
|
-
` pid ${lock.pid}, started ${since}, CLI ${lock.version || "?"}, in ${holderWhere(lock)}.`
|
|
1898
|
-
];
|
|
1899
|
-
if (verdict === "unresponsive") {
|
|
1900
|
-
lines.push(` It is NOT answering its health check \u2014 it may be asleep, stopped, or the machine may be overloaded.`);
|
|
1901
|
-
}
|
|
1902
|
-
if (verdict === "unknown") {
|
|
1903
|
-
lines.push(` Its lock file could not be read (a newer CLI may have written it), so it is assumed to be running.`);
|
|
1904
|
-
}
|
|
1905
|
-
lines.push("", " What you can do:");
|
|
1906
|
-
if (lock.launch === "tmux" && lock.launchDetail) lines.push(` tmux attach -t ${lock.launchDetail} \u2014 go to the one that is running`);
|
|
1907
|
-
lines.push(
|
|
1908
|
-
` pags status \u2014 what it is doing, and anything else running here`,
|
|
1909
|
-
lock.launch === "service" ? ` (this one is a service \u2014 stop its launchd/systemd unit rather than replacing it)` : ` pags up --replace \u2014 stop that one gracefully, then take over`
|
|
1910
|
-
);
|
|
1911
|
-
return lines;
|
|
1912
|
-
}
|
|
1913
|
-
function detectLaunch(env = process.env, isTty = Boolean(process.stdout.isTTY)) {
|
|
1914
|
-
if (env.PAGS_SERVICE === "1") return { launch: "service", detail: env.PAGS_SERVICE_NAME?.trim() || "" };
|
|
1915
|
-
if (env.TMUX) return { launch: "tmux", detail: env.TMUX_PANE?.trim() || env.TMUX.split(",")[0]?.split("/").pop() || "" };
|
|
1916
|
-
if (!isTty) return { launch: "headless", detail: "" };
|
|
1917
|
-
return { launch: "tty", detail: env.TERM_SESSION_ID?.trim() || env.WINDOWID?.trim() || "" };
|
|
1918
|
-
}
|
|
1919
|
-
function pidAlive(pid) {
|
|
1920
|
-
if (!pid || !Number.isInteger(pid) || pid <= 0) return false;
|
|
1921
|
-
try {
|
|
1922
|
-
process.kill(pid, 0);
|
|
1923
|
-
return true;
|
|
1924
|
-
} catch (e) {
|
|
1925
|
-
return e.code === "EPERM";
|
|
1926
|
-
}
|
|
1927
|
-
}
|
|
1928
|
-
function pidStartedAt(pid) {
|
|
1929
|
-
if (!pidAlive(pid)) return "";
|
|
1930
|
-
try {
|
|
1931
|
-
if (process.platform === "win32") {
|
|
1932
|
-
const out = execFileSync2("wmic", ["process", "where", `ProcessId=${pid}`, "get", "CreationDate", "/value"], { encoding: "utf-8", timeout: 4e3, stdio: ["ignore", "pipe", "ignore"] });
|
|
1933
|
-
return (out.match(/CreationDate=(\S+)/)?.[1] ?? "").trim();
|
|
1934
|
-
}
|
|
1935
|
-
return execFileSync2("ps", ["-o", "lstart=", "-p", String(pid)], { encoding: "utf-8", timeout: 4e3, stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
1936
|
-
} catch {
|
|
1937
|
-
return "";
|
|
1938
|
-
}
|
|
1939
|
-
}
|
|
1940
|
-
async function healthRsid(port, token = "", timeoutMs = 5e3, fetchImpl = fetch) {
|
|
1941
|
-
if (!port) return null;
|
|
1942
|
-
try {
|
|
1943
|
-
const res = await fetchImpl(`http://127.0.0.1:${port}/health`, { headers: token ? { Authorization: `Bearer ${token}` } : {}, signal: AbortSignal.timeout(timeoutMs) });
|
|
1944
|
-
if (!res.ok) return null;
|
|
1945
|
-
const body = await res.json();
|
|
1946
|
-
return typeof body.rsid === "string" && body.rsid ? body.rsid : null;
|
|
1947
|
-
} catch {
|
|
1948
|
-
return null;
|
|
1949
|
-
}
|
|
1950
|
-
}
|
|
1951
|
-
function readLock(path) {
|
|
1952
|
-
try {
|
|
1953
|
-
const parsed = JSON.parse(readFileSync6(path, "utf-8"));
|
|
1954
|
-
return parsed && typeof parsed === "object" && typeof parsed.rsid === "string" ? parsed : null;
|
|
1955
|
-
} catch {
|
|
1956
|
-
return null;
|
|
1957
|
-
}
|
|
1958
|
-
}
|
|
1959
|
-
async function inspectHolder(path, opts = {}) {
|
|
1960
|
-
const lock = readLock(path);
|
|
1961
|
-
if (!lock) return { lock: null, verdict: "unknown" };
|
|
1962
|
-
const alive = pidAlive(lock.pid);
|
|
1963
|
-
const facts = {
|
|
1964
|
-
lock,
|
|
1965
|
-
pidAlive: alive,
|
|
1966
|
-
pidStartedAt: alive ? pidStartedAt(lock.pid) : "",
|
|
1967
|
-
healthRsid: alive ? await healthRsid(lock.port, lock.runnerToken ?? "", opts.healthTimeoutMs ?? 5e3, opts.fetchImpl ?? fetch) : null
|
|
1968
|
-
};
|
|
1969
|
-
return { lock, verdict: classifyHolder(facts) };
|
|
1970
|
-
}
|
|
1971
|
-
async function acquireLock(input) {
|
|
1972
|
-
const env = input.env ?? process.env;
|
|
1973
|
-
const path = lockPath(input.account, env);
|
|
1974
|
-
const rsid = input.rsid ?? randomUUID2();
|
|
1975
|
-
const handoff = input.handoffFrom?.trim() || env.PAGS_LOCK_HANDOFF?.trim() || "";
|
|
1976
|
-
mkdirSync5(configDir(env), { recursive: true });
|
|
1977
|
-
for (let attempt = 0; attempt < 3; attempt++) {
|
|
1978
|
-
const written = tryWrite(path, buildLock(input, rsid, env));
|
|
1979
|
-
if (written) return { ok: true, lock: written, path, tookOver: attempt === 0 ? "none" : "stale" };
|
|
1980
|
-
const { lock: lock2, verdict: verdict2 } = await inspectHolder(path);
|
|
1981
|
-
if (handoff && lock2?.rsid === handoff) {
|
|
1982
|
-
renameAside(path, lock2.pid);
|
|
1983
|
-
const taken = tryWrite(path, buildLock(input, rsid, env));
|
|
1984
|
-
if (taken) return { ok: true, lock: taken, path, tookOver: "handoff" };
|
|
1985
|
-
continue;
|
|
1986
|
-
}
|
|
1987
|
-
if (!isTakeable(verdict2)) return { ok: false, lock: lock2, path, verdict: verdict2 };
|
|
1988
|
-
if (!takeAsideIfStill(path, lock2?.rsid ?? "", lock2?.pid ?? 0)) continue;
|
|
1989
|
-
}
|
|
1990
|
-
const { lock, verdict } = await inspectHolder(path);
|
|
1991
|
-
return { ok: false, lock, path, verdict };
|
|
1992
|
-
}
|
|
1993
|
-
function buildLock(input, rsid, env) {
|
|
1994
|
-
const { launch, detail } = detectLaunch(env);
|
|
1995
|
-
const now = Date.now();
|
|
1996
|
-
return {
|
|
1997
|
-
v: 1,
|
|
1998
|
-
rsid,
|
|
1999
|
-
pid: process.pid,
|
|
2000
|
-
pidStartedAt: pidStartedAt(process.pid),
|
|
2001
|
-
startedAt: now,
|
|
2002
|
-
port: input.port,
|
|
2003
|
-
node: input.node,
|
|
2004
|
-
machineId: input.machineId,
|
|
2005
|
-
account: input.account,
|
|
2006
|
-
version: input.version,
|
|
2007
|
-
launch,
|
|
2008
|
-
launchDetail: detail,
|
|
2009
|
-
childPids: [],
|
|
2010
|
-
nonce: randomUUID2(),
|
|
2011
|
-
runnerToken: input.runnerToken ?? "",
|
|
2012
|
-
heartbeatAt: now
|
|
2013
|
-
};
|
|
2014
|
-
}
|
|
2015
|
-
function tryWrite(path, lock) {
|
|
2016
|
-
let fd = null;
|
|
2017
|
-
try {
|
|
2018
|
-
fd = openSync(path, "wx", 384);
|
|
2019
|
-
writeSync(fd, JSON.stringify(lock, null, 2));
|
|
2020
|
-
return lock;
|
|
2021
|
-
} catch (e) {
|
|
2022
|
-
if (e.code === "EEXIST") return null;
|
|
2023
|
-
throw e;
|
|
2024
|
-
} finally {
|
|
2025
|
-
if (fd !== null) closeSync(fd);
|
|
2026
|
-
}
|
|
2027
|
-
}
|
|
2028
|
-
function renameAside(path, pid) {
|
|
2029
|
-
const aside = `${path}.stale-${pid || "unknown"}`;
|
|
2030
|
-
try {
|
|
2031
|
-
renameSync(path, aside);
|
|
2032
|
-
return aside;
|
|
2033
|
-
} catch {
|
|
2034
|
-
return null;
|
|
2035
|
-
}
|
|
2036
|
-
}
|
|
2037
|
-
function takeAsideIfStill(path, deadRsid, pid) {
|
|
2038
|
-
const aside = renameAside(path, pid);
|
|
2039
|
-
if (!aside) return false;
|
|
2040
|
-
const moved = readLock(aside);
|
|
2041
|
-
if (moved && deadRsid && moved.rsid !== deadRsid) {
|
|
2042
|
-
try {
|
|
2043
|
-
renameSync(aside, path);
|
|
2044
|
-
} catch {
|
|
2045
|
-
}
|
|
2046
|
-
return false;
|
|
2047
|
-
}
|
|
2048
|
-
return true;
|
|
2049
|
-
}
|
|
2050
|
-
function releaseLock(path, rsid) {
|
|
2051
|
-
const current = readLock(path);
|
|
2052
|
-
if (!current || current.rsid !== rsid) return false;
|
|
2053
|
-
try {
|
|
2054
|
-
unlinkSync2(path);
|
|
2055
|
-
return true;
|
|
2056
|
-
} catch {
|
|
2057
|
-
return false;
|
|
2058
|
-
}
|
|
2059
|
-
}
|
|
2060
|
-
|
|
2061
2379
|
// src/commands/runner/command.ts
|
|
2062
2380
|
import { hostname as hostname4 } from "os";
|
|
2063
2381
|
function collectCapability(value, previous = []) {
|