@proagentstore/cli 0.4.94 → 0.4.96
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/coding/runtime.js +5 -5
- package/dist/browser-runner/local-apply/contract.js +16 -4
- package/dist/browser-runner/local-apply/handoff-routes.js +21 -0
- package/dist/browser-runner/local-apply/runtime-handoff.js +117 -0
- package/dist/browser-runner/local-apply/runtime.js +13 -0
- package/dist/browser-runner/local-apply/takeover-adapter.js +28 -0
- package/dist/browser-runner/local-artifact/runtime.js +13 -0
- package/dist/browser-runner/runner.js +8 -2
- package/dist/browser-runner/server.js +4 -1
- package/dist/index.js +80 -24
- package/package.json +1 -1
|
@@ -21,9 +21,9 @@ export class CodingRuntime {
|
|
|
21
21
|
* answer rather than restarting across work whose state it cannot prove is idle.
|
|
22
22
|
*/
|
|
23
23
|
liveWork() {
|
|
24
|
-
return [...this.sessions.
|
|
24
|
+
return [...this.sessions.entries()].flatMap(([sessionId, s]) => {
|
|
25
25
|
const engine = s.clientType ?? "";
|
|
26
|
-
const
|
|
26
|
+
const consequence = engine === "claude" ? "Claude conversation resumes after a restart" : `${engine || "coding"} turn is lost on a restart`;
|
|
27
27
|
try {
|
|
28
28
|
// `alive: false` is an observed inactive session. Any other answer (including a
|
|
29
29
|
// malformed older implementation) is unknown and must fail closed below.
|
|
@@ -34,13 +34,13 @@ export class CodingRuntime {
|
|
|
34
34
|
if (alive === true && state === "idle")
|
|
35
35
|
return [];
|
|
36
36
|
if (alive === true && (state === "thinking" || state === "responding"))
|
|
37
|
-
return [
|
|
37
|
+
return [`coding session ${sessionId} (${state}): ${consequence}`];
|
|
38
38
|
// An unrecognised `alive`/`runState` pair is not proof that an engine is idle.
|
|
39
|
-
return [
|
|
39
|
+
return [`coding session ${sessionId} (${typeof state === "string" ? state : "unknown"}): ${consequence}`];
|
|
40
40
|
}
|
|
41
41
|
catch {
|
|
42
42
|
// Do not turn a failed state probe into permission to restart the machine.
|
|
43
|
-
return [
|
|
43
|
+
return [`coding session ${sessionId} (unknown): ${consequence}`];
|
|
44
44
|
}
|
|
45
45
|
});
|
|
46
46
|
}
|
|
@@ -35,6 +35,17 @@ export const LOCAL_APPLY_CANCEL_PATH = "/local-apply/cancel";
|
|
|
35
35
|
export const LOCAL_APPLY_PREFLIGHT_PATH = "/local-apply/preflight";
|
|
36
36
|
/** Delivers a cloud-persisted supervisory decision to one paused checkpoint. */
|
|
37
37
|
export const LOCAL_APPLY_DIRECTIVE_PATH = "/local-apply/directive";
|
|
38
|
+
/**
|
|
39
|
+
* A short-lived, owner-authorized view of the *existing* browser page for one local-apply run.
|
|
40
|
+
* The id is opaque and carries no browser/profile material. It deliberately is not a task id:
|
|
41
|
+
* local-apply runs are not generic runner tasks.
|
|
42
|
+
*/
|
|
43
|
+
export const LOCAL_APPLY_HANDOFF_PATH = "/local-apply/handoff";
|
|
44
|
+
export const LOCAL_APPLY_HANDOFF_STATUS_PATH = "/local-apply/handoff-status";
|
|
45
|
+
export const LOCAL_APPLY_HANDOFF_FRAME_PATH = "/local-apply/handoff/frame";
|
|
46
|
+
export const LOCAL_APPLY_HANDOFF_INPUT_PATH = "/local-apply/handoff/input";
|
|
47
|
+
export const LOCAL_APPLY_HANDOFF_RESUME_PATH = "/local-apply/handoff/resume";
|
|
48
|
+
export const LOCAL_APPLY_HANDOFF_END_PATH = "/local-apply/handoff/end";
|
|
38
49
|
export const LOCAL_APPLY_ENGINES = ["claude", "codex"];
|
|
39
50
|
export const LOCAL_APPLY_AUTH_MODES = ["machine", "subscription"];
|
|
40
51
|
export const LOCAL_APPLY_MODES = ["fill_and_review", "auto_submit"];
|
|
@@ -84,7 +95,7 @@ export const LOCAL_APPLY_BLOCK_REASONS = [
|
|
|
84
95
|
* dispatch, naming the update, rather than silently producing the older behaviour (`apply.ts`
|
|
85
96
|
* `runnerContractProblem`).
|
|
86
97
|
*
|
|
87
|
-
* ── Why it moved to 0.4.
|
|
98
|
+
* ── Why it moved to 0.4.96 (#1013)
|
|
88
99
|
*
|
|
89
100
|
* #953 adds the read-only `/local-apply/preflight` route used before an email lead can enter
|
|
90
101
|
* tailoring. That route lives in `packages/browser-runner`, which ships INSIDE the published CLI.
|
|
@@ -92,15 +103,15 @@ export const LOCAL_APPLY_BLOCK_REASONS = [
|
|
|
92
103
|
* not silently look equivalent to a current runner.
|
|
93
104
|
*
|
|
94
105
|
* A behaviour change in the runner is a CONTRACT change, because the cloud's decisions assume it.
|
|
95
|
-
* So this floor moves with it:
|
|
96
|
-
*
|
|
106
|
+
* So this floor moves with it: #1013's exact-page login/CAPTCHA handoff and post-submit receipt
|
|
107
|
+
* evidence are refused together rather than silently degrading to an unhelpful `submit_unconfirmed` state.
|
|
97
108
|
*
|
|
98
109
|
* Bump this when the runner's half of the contract changes again, together with the CLI version
|
|
99
110
|
* that ships it — `policy.test.ts` pins the pair, so the floor cannot name a release that has not
|
|
100
111
|
* been published. `cliAtLeast` treats an unreported version as capable, which is the convention
|
|
101
112
|
* every MIN_CLI gate here follows.
|
|
102
113
|
*/
|
|
103
|
-
export const LOCAL_APPLY_CONTRACT_MIN_CLI = "0.4.
|
|
114
|
+
export const LOCAL_APPLY_CONTRACT_MIN_CLI = "0.4.96";
|
|
104
115
|
export const LOCAL_APPLY_DIAGNOSTIC_CAUSES = ["bridge_unused", "engine_exited_nonzero", "timed_out", "no_engine_output", "submit_unconfirmed"];
|
|
105
116
|
export const LOCAL_APPLY_CONFIRMATION_MARKERS = ["page_text", "url_receipt", "already_applied_notice"];
|
|
106
117
|
export const LOCAL_APPLY_SIGNALS = [
|
|
@@ -140,6 +151,7 @@ export const LOCAL_APPLY_EVENT_TYPES = [
|
|
|
140
151
|
export const LOCAL_APPLY_ENGINE_AUTHS = ["machine-login", "subscription", "api-key", "missing_login", "unknown"];
|
|
141
152
|
export const LOCAL_APPLY_SUPERVISOR_DIRECTIVES = ["continue", "request_review", "stop"];
|
|
142
153
|
export const LOCAL_APPLY_SUPERVISOR_PHASES = ["initial", "before_submit", "post_navigation", "uncertain"];
|
|
154
|
+
export const LOCAL_APPLY_HANDOFF_TERMINAL_REASONS = ["unavailable", "profile_unavailable", "profile_mismatch", "page_lost", "expired", "run_ended", "runner_restarted"];
|
|
143
155
|
export const LOCAL_APPLY_CAPS = { questions: 20, questionChars: 300, answers: 20, answerChars: 2000, summary: 2000 };
|
|
144
156
|
const str = (v, max) => (typeof v === "string" && v.trim() ? v.trim().slice(0, max) : null);
|
|
145
157
|
const oneOf = (list, v) => (list.includes(v) ? v : null);
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The local-apply takeover surface is separate from generic task takeover: every request carries
|
|
3
|
+
* the exact run/application/profile binding that the runtime verifies before it reaches a page.
|
|
4
|
+
*/
|
|
5
|
+
export async function routeLocalApplyHandoff(runtime, method, path, read) {
|
|
6
|
+
if (method !== "POST")
|
|
7
|
+
return undefined;
|
|
8
|
+
if (path === "/local-apply/handoff")
|
|
9
|
+
return runtime.handoff(await read());
|
|
10
|
+
if (path === "/local-apply/handoff-status")
|
|
11
|
+
return runtime.handoffStatus(await read());
|
|
12
|
+
if (path === "/local-apply/handoff/frame")
|
|
13
|
+
return runtime.handoffFrame(await read());
|
|
14
|
+
if (path === "/local-apply/handoff/input") {
|
|
15
|
+
await runtime.handoffInput(await read());
|
|
16
|
+
return { ok: true };
|
|
17
|
+
}
|
|
18
|
+
if (path === "/local-apply/handoff/end" || path === "/local-apply/handoff/resume")
|
|
19
|
+
return runtime.endHandoff(await read());
|
|
20
|
+
return undefined;
|
|
21
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { RunnerInputError } from "../errors.js";
|
|
2
|
+
const SAFE_ID = /^[A-Za-z0-9_.:-]{1,300}$/;
|
|
3
|
+
const HANDOFF_TTL_MS = 10 * 60 * 1000;
|
|
4
|
+
/** Exact-page lifecycle only. It deliberately has no access to a bridge, browser credentials, or run output. */
|
|
5
|
+
export class LocalApplyHandoffRuntime {
|
|
6
|
+
host;
|
|
7
|
+
constructor(host) {
|
|
8
|
+
this.host = host;
|
|
9
|
+
}
|
|
10
|
+
async handoff(raw) {
|
|
11
|
+
const request = this.parse(raw);
|
|
12
|
+
const run = this.host.get(request.runId);
|
|
13
|
+
this.assertBinding(run, request);
|
|
14
|
+
if (run.state === "ended")
|
|
15
|
+
return this.closed(run, request.handoffId, "run_ended");
|
|
16
|
+
if (run.handoff)
|
|
17
|
+
throw new RunnerInputError("A handoff already exists for this application run", 409);
|
|
18
|
+
if (!this.host.takeover)
|
|
19
|
+
return this.closed(run, request.handoffId, "unavailable");
|
|
20
|
+
try {
|
|
21
|
+
await this.host.takeover.open(request);
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
return this.closed(run, request.handoffId, "profile_unavailable");
|
|
25
|
+
}
|
|
26
|
+
run.handoff = { handoffId: request.handoffId, expiresAt: this.host.now() + (this.host.handoffTtlMs ?? HANDOFF_TTL_MS), state: "ready" };
|
|
27
|
+
return this.status(request);
|
|
28
|
+
}
|
|
29
|
+
async status(raw) {
|
|
30
|
+
const request = this.parse(raw);
|
|
31
|
+
const run = this.host.get(request.runId);
|
|
32
|
+
this.assertBinding(run, request);
|
|
33
|
+
if (run.state === "ended")
|
|
34
|
+
return this.closed(run, request.handoffId, "run_ended");
|
|
35
|
+
const handoff = run.handoff;
|
|
36
|
+
if (!handoff || handoff.handoffId !== request.handoffId)
|
|
37
|
+
return this.closed(run, request.handoffId, "unavailable");
|
|
38
|
+
if (handoff.state === "closed")
|
|
39
|
+
return this.response(run, handoff);
|
|
40
|
+
if (handoff.expiresAt <= this.host.now())
|
|
41
|
+
await this.close(run, "expired");
|
|
42
|
+
else if (!this.host.takeover || (await this.host.takeover.state(handoff.handoffId)) !== "ready")
|
|
43
|
+
await this.close(run, "page_lost");
|
|
44
|
+
return this.response(run, run.handoff);
|
|
45
|
+
}
|
|
46
|
+
async frame(raw) {
|
|
47
|
+
const { run, handoff } = await this.live(raw);
|
|
48
|
+
try {
|
|
49
|
+
return await this.host.takeover.frame(handoff.handoffId);
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
await this.close(run, "page_lost");
|
|
53
|
+
throw new RunnerInputError("The handoff page is no longer available", 409);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
async input(raw) {
|
|
57
|
+
const o = raw && typeof raw === "object" ? raw : {};
|
|
58
|
+
const { run, handoff } = await this.live(o);
|
|
59
|
+
if (!o.input || typeof o.input !== "object")
|
|
60
|
+
throw new RunnerInputError("handoff input is required");
|
|
61
|
+
try {
|
|
62
|
+
await this.host.takeover.input(handoff.handoffId, o.input);
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
await this.close(run, "page_lost");
|
|
66
|
+
throw new RunnerInputError("The handoff page is no longer available", 409);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
async end(raw) {
|
|
70
|
+
const request = this.parse(raw);
|
|
71
|
+
const run = this.host.get(request.runId);
|
|
72
|
+
this.assertBinding(run, request);
|
|
73
|
+
if (!run.handoff || run.handoff.handoffId !== request.handoffId)
|
|
74
|
+
return this.closed(run, request.handoffId, "unavailable");
|
|
75
|
+
await this.close(run, "unavailable");
|
|
76
|
+
return this.response(run, run.handoff);
|
|
77
|
+
}
|
|
78
|
+
parse(raw) {
|
|
79
|
+
const o = raw && typeof raw === "object" ? raw : {};
|
|
80
|
+
const handoffId = typeof o.handoffId === "string" ? o.handoffId : "";
|
|
81
|
+
const runId = typeof o.runId === "string" ? o.runId : "";
|
|
82
|
+
const applicationId = typeof o.applicationId === "string" ? o.applicationId : "";
|
|
83
|
+
const browserProfile = o.browserProfile === "default" || o.browserProfile === "isolated" ? o.browserProfile : null;
|
|
84
|
+
if (!SAFE_ID.test(handoffId) || !SAFE_ID.test(runId) || !SAFE_ID.test(applicationId) || !browserProfile)
|
|
85
|
+
throw new RunnerInputError("handoff needs opaque handoffId, runId, applicationId, and browserProfile");
|
|
86
|
+
return { handoffId, runId, applicationId, browserProfile };
|
|
87
|
+
}
|
|
88
|
+
assertBinding(run, request) {
|
|
89
|
+
if (run.envelope.applicationId !== request.applicationId || run.envelope.browserProfile !== request.browserProfile)
|
|
90
|
+
throw new RunnerInputError("The handoff does not match this application run", 403);
|
|
91
|
+
}
|
|
92
|
+
response(run, handoff) {
|
|
93
|
+
return { handoffId: handoff.handoffId, runId: run.envelope.runId, applicationId: run.envelope.applicationId, browserProfile: run.envelope.browserProfile, state: handoff.state, expiresAt: new Date(handoff.expiresAt).toISOString(), ...(handoff.terminalReason ? { terminalReason: handoff.terminalReason } : {}) };
|
|
94
|
+
}
|
|
95
|
+
closed(run, handoffId, terminalReason) {
|
|
96
|
+
const handoff = { handoffId, expiresAt: this.host.now(), state: "closed", terminalReason };
|
|
97
|
+
if (run.handoff?.handoffId === handoffId)
|
|
98
|
+
run.handoff = handoff;
|
|
99
|
+
return this.response(run, handoff);
|
|
100
|
+
}
|
|
101
|
+
async close(run, reason) {
|
|
102
|
+
const handoff = run.handoff;
|
|
103
|
+
if (!handoff || handoff.state === "closed")
|
|
104
|
+
return;
|
|
105
|
+
handoff.state = "closed";
|
|
106
|
+
handoff.terminalReason = reason;
|
|
107
|
+
if (this.host.takeover)
|
|
108
|
+
await this.host.takeover.end(handoff.handoffId).catch(() => undefined);
|
|
109
|
+
}
|
|
110
|
+
async live(raw) {
|
|
111
|
+
const status = await this.status(raw);
|
|
112
|
+
if (status.state !== "ready")
|
|
113
|
+
throw new RunnerInputError(`The handoff is closed (${status.terminalReason ?? "unavailable"})`, 409);
|
|
114
|
+
const run = this.host.get(status.runId);
|
|
115
|
+
return { run, handoff: run.handoff };
|
|
116
|
+
}
|
|
117
|
+
}
|
|
@@ -37,6 +37,7 @@ import { ApplyBridge } from "./bridge.js";
|
|
|
37
37
|
import { confirmationSignals } from "./confirmation.js";
|
|
38
38
|
import { LOCAL_APPLY_AUTH_MODES, LOCAL_APPLY_CAPS, LOCAL_APPLY_ENGINES, LOCAL_APPLY_MODES, LOCAL_APPLY_TASK_TYPE, parseLocalApplyResult, } from "./contract.js";
|
|
39
39
|
import { applyPrompt, buildApplyEngineSpec, observedApplyAuth } from "./engine.js";
|
|
40
|
+
import { LocalApplyHandoffRuntime } from "./runtime-handoff.js";
|
|
40
41
|
const MAX_EVENTS = 1000;
|
|
41
42
|
const OUTPUT_LINES = 400;
|
|
42
43
|
const SOURCE_BYTES = 256 * 1024;
|
|
@@ -169,6 +170,7 @@ function bridgeUnusedHelp(engine, d) {
|
|
|
169
170
|
export class LocalApplyRuntime {
|
|
170
171
|
deps;
|
|
171
172
|
runs = new Map();
|
|
173
|
+
handoffs;
|
|
172
174
|
/** Application fills that a restart would lose (#896) — their state is in this process. */
|
|
173
175
|
liveWork() {
|
|
174
176
|
return [...this.runs.values()].filter((r) => r.state !== "ended").map((r) => `an application fill (${r.envelope.applicationId.slice(0, 8)})`);
|
|
@@ -183,6 +185,7 @@ export class LocalApplyRuntime {
|
|
|
183
185
|
this.now = deps.now ?? Date.now;
|
|
184
186
|
this.spawn = deps.spawn ?? nodeSpawn;
|
|
185
187
|
this.retentionMs = deps.retentionMs ?? 24 * 60 * 60 * 1000;
|
|
188
|
+
this.handoffs = new LocalApplyHandoffRuntime({ get: (runId) => this.get(runId), now: this.now, takeover: deps.takeover, handoffTtlMs: deps.handoffTtlMs });
|
|
186
189
|
this.hydrateTerminalJournals();
|
|
187
190
|
}
|
|
188
191
|
/**
|
|
@@ -650,6 +653,12 @@ export class LocalApplyRuntime {
|
|
|
650
653
|
...(run.result ? { result: run.result } : {}),
|
|
651
654
|
};
|
|
652
655
|
}
|
|
656
|
+
/** A takeover only views the exact live page; it never authorizes the paused bridge. */
|
|
657
|
+
async handoff(raw) { return this.handoffs.handoff(raw); }
|
|
658
|
+
async handoffStatus(raw) { return this.handoffs.status(raw); }
|
|
659
|
+
async handoffFrame(raw) { return this.handoffs.frame(raw); }
|
|
660
|
+
async handoffInput(raw) { return this.handoffs.input(raw); }
|
|
661
|
+
async endHandoff(raw) { return this.handoffs.end(raw); }
|
|
653
662
|
/** Does this token belong to this run? The bridge forwarder's only credential. */
|
|
654
663
|
authorizeBridge(runId, token) {
|
|
655
664
|
const run = this.runs.get(runId);
|
|
@@ -689,6 +698,10 @@ export class LocalApplyRuntime {
|
|
|
689
698
|
end(run, r) {
|
|
690
699
|
if (run.state === "ended")
|
|
691
700
|
return;
|
|
701
|
+
// A terminal application outcome never means that a handoff succeeded. Tear down only the
|
|
702
|
+
// live view and retain the explicit closed state for a caller that polls before restart.
|
|
703
|
+
if (run.handoff?.state === "ready")
|
|
704
|
+
void this.handoffs.close(run, "run_ended");
|
|
692
705
|
if (run.state === "paused")
|
|
693
706
|
this.release(run, "stopped");
|
|
694
707
|
if (run.timer)
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { RunnerInputError } from "../errors.js";
|
|
2
|
+
const prefix = "local-apply:";
|
|
3
|
+
export function isLocalApplyTakeover(id) { return id.startsWith(prefix); }
|
|
4
|
+
/** Attach the bounded local-apply view to the runner's existing live-page takeover machinery. */
|
|
5
|
+
export function createLocalApplyTakeoverAdapter(deps) {
|
|
6
|
+
const key = (handoffId) => `${prefix}${handoffId}`;
|
|
7
|
+
return {
|
|
8
|
+
async open(request) {
|
|
9
|
+
if (request.browserProfile !== "default")
|
|
10
|
+
throw new RunnerInputError("This browser profile has no live handoff page", 409);
|
|
11
|
+
const id = key(request.handoffId);
|
|
12
|
+
if (deps.get(id))
|
|
13
|
+
throw new RunnerInputError("A local application handoff already exists", 409);
|
|
14
|
+
// Do not create a page here: a destroyed page must remain a lost handoff, never look ready.
|
|
15
|
+
const page = deps.activePage();
|
|
16
|
+
if (!page || page.isClosed())
|
|
17
|
+
throw new RunnerInputError("The local application page is no longer available", 409);
|
|
18
|
+
deps.set(id, page);
|
|
19
|
+
},
|
|
20
|
+
async state(handoffId) {
|
|
21
|
+
const session = deps.get(key(handoffId));
|
|
22
|
+
return session && !session.page.isClosed() ? "ready" : "page_lost";
|
|
23
|
+
},
|
|
24
|
+
frame: (handoffId) => deps.frame(key(handoffId)),
|
|
25
|
+
input: (handoffId, input) => deps.input(key(handoffId), input),
|
|
26
|
+
end: (handoffId) => deps.end(key(handoffId)),
|
|
27
|
+
};
|
|
28
|
+
}
|
|
@@ -401,6 +401,19 @@ export class LocalArtifactRuntime {
|
|
|
401
401
|
return;
|
|
402
402
|
if (run.timer)
|
|
403
403
|
clearTimeout(run.timer);
|
|
404
|
+
// An uploaded source is a bounded, per-run transfer, not runner state. The durable
|
|
405
|
+
// result deliberately contains only its handle and hash; remove the materialized text as
|
|
406
|
+
// soon as the engine no longer needs it instead of retaining it with generic scratch data.
|
|
407
|
+
// `end` is the one terminal path (completion, refusal, timeout, cancellation and launch
|
|
408
|
+
// failure), so a source cannot linger just because a particular failure path was missed.
|
|
409
|
+
if (run.envelope.uploadedSources?.length) {
|
|
410
|
+
try {
|
|
411
|
+
rmSync(join(run.scratch, "sources"), { recursive: true, force: true });
|
|
412
|
+
}
|
|
413
|
+
catch { /* best-effort cleanup */ }
|
|
414
|
+
for (const source of run.envelope.uploadedSources)
|
|
415
|
+
source.text = "";
|
|
416
|
+
}
|
|
404
417
|
run.state = "ended";
|
|
405
418
|
run.endedAt = this.now();
|
|
406
419
|
run.result = {
|
|
@@ -18,6 +18,7 @@ import { LocalArtifactRuntime } from "./local-artifact/runtime.js";
|
|
|
18
18
|
import { LOCAL_ARTIFACT_TASK_TYPE } from "./local-artifact/contract.js";
|
|
19
19
|
import { LocalApplyRuntime } from "./local-apply/runtime.js";
|
|
20
20
|
import { LOCAL_APPLY_TASK_TYPE } from "./local-apply/contract.js";
|
|
21
|
+
import { createLocalApplyTakeoverAdapter, isLocalApplyTakeover } from "./local-apply/takeover-adapter.js";
|
|
21
22
|
import { WORKFLOW_DRIVEN_TASKS } from "./task-types.js";
|
|
22
23
|
/** True for a plain object. */
|
|
23
24
|
function isRecord(value) {
|
|
@@ -105,7 +106,12 @@ export class LocalRunner {
|
|
|
105
106
|
};
|
|
106
107
|
this.localBrowser = new LocalBrowserRuntime({ dataDir: config.dataDir, selfUrl: () => this.selfUrl, browserFor });
|
|
107
108
|
this.localArtifact = new LocalArtifactRuntime({ dataDir: config.dataDir });
|
|
108
|
-
this.localApply = new LocalApplyRuntime({
|
|
109
|
+
this.localApply = new LocalApplyRuntime({
|
|
110
|
+
dataDir: config.dataDir,
|
|
111
|
+
selfUrl: () => this.selfUrl,
|
|
112
|
+
browserFor,
|
|
113
|
+
takeover: createLocalApplyTakeoverAdapter({ activePage: () => this.activePage, get: (id) => this.takeovers.get(id), set: (id, page) => this.takeovers.set(id, { page, reason: "local_apply", humanDone: false }), frame: (id) => this.takeoverFrame(id), input: (id, input) => this.takeoverInput(id, input), end: (id) => this.endTakeover(id) }),
|
|
114
|
+
});
|
|
109
115
|
// Tasks paused/running on a previous process are orphaned now — their
|
|
110
116
|
// pages and takeover sessions are gone. Fail them so the board is clean.
|
|
111
117
|
const expired = this.store.expireInFlightTasks();
|
|
@@ -365,7 +371,7 @@ export class LocalRunner {
|
|
|
365
371
|
}
|
|
366
372
|
/** Active human-takeover task ids (for status/discovery). */
|
|
367
373
|
listTakeovers() {
|
|
368
|
-
return [...this.takeovers.keys()];
|
|
374
|
+
return [...this.takeovers.keys()].filter((id) => !isLocalApplyTakeover(id));
|
|
369
375
|
}
|
|
370
376
|
requireTakeover(taskId) {
|
|
371
377
|
const session = this.takeovers.get(taskId);
|
|
@@ -4,6 +4,7 @@ import { LocalRunner, RunnerInputError } from "./runner.js";
|
|
|
4
4
|
import { probeGitSshIdentity } from "./coding/repo.js";
|
|
5
5
|
import { checkEngine } from "./coding/engine-check.js";
|
|
6
6
|
import { listGithubOrgs, listGithubRepos, searchGithubRepos, getGithubRepoDetail, getGithubCredentialScope } from "./coding/github-browse.js";
|
|
7
|
+
import { routeLocalApplyHandoff } from "./local-apply/handoff-routes.js";
|
|
7
8
|
export function createRunnerServer(runner) {
|
|
8
9
|
return createServer(async (req, res) => {
|
|
9
10
|
try {
|
|
@@ -226,7 +227,6 @@ async function route(runner, req, res) {
|
|
|
226
227
|
return json(res, 200, runner.localArtifact.cancel(await readJson(req)));
|
|
227
228
|
}
|
|
228
229
|
// 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
230
|
if (req.method === "POST" && path === "/local-apply/preflight") {
|
|
231
231
|
return json(res, 200, await runner.localApply.preflight(await readJson(req)));
|
|
232
232
|
}
|
|
@@ -236,6 +236,9 @@ async function route(runner, req, res) {
|
|
|
236
236
|
if (req.method === "POST" && path === "/local-apply/status") {
|
|
237
237
|
return json(res, 200, runner.localApply.status(await readJson(req)));
|
|
238
238
|
}
|
|
239
|
+
const handoff = await routeLocalApplyHandoff(runner.localApply, req.method, path, () => readJson(req));
|
|
240
|
+
if (handoff)
|
|
241
|
+
return json(res, 200, handoff);
|
|
239
242
|
if (req.method === "POST" && path === "/local-apply/resume") {
|
|
240
243
|
return json(res, 200, runner.localApply.resume(await readJson(req)));
|
|
241
244
|
}
|
package/dist/index.js
CHANGED
|
@@ -1269,6 +1269,19 @@ function restarterFrom(env) {
|
|
|
1269
1269
|
function supervisorNote(restarter) {
|
|
1270
1270
|
return restarter === "pags-up-child-only" ? "The `pags up` on this machine predates supervisor restarts (#860): the runner comes back on the new release, but the `pags up` window keeps running its own older code until it is restarted there once." : null;
|
|
1271
1271
|
}
|
|
1272
|
+
var MAX_DEFERRED_UPDATE_LEASE_MS = 5 * 6e4;
|
|
1273
|
+
function deferredUpdateLease(value, now = Date.now()) {
|
|
1274
|
+
if (!value || typeof value !== "object") return null;
|
|
1275
|
+
const raw = value;
|
|
1276
|
+
const operationId = typeof raw.operationId === "string" ? raw.operationId.trim() : "";
|
|
1277
|
+
const expiresAt = typeof raw.deferUntil === "number" ? raw.deferUntil : NaN;
|
|
1278
|
+
if (!operationId || operationId.length > 128 || !Number.isFinite(expiresAt)) return null;
|
|
1279
|
+
if (expiresAt <= now || expiresAt > now + MAX_DEFERRED_UPDATE_LEASE_MS) return null;
|
|
1280
|
+
return { operationId, expiresAt };
|
|
1281
|
+
}
|
|
1282
|
+
function deferredUpdateLeaseActive(lease, now = Date.now()) {
|
|
1283
|
+
return now < lease.expiresAt;
|
|
1284
|
+
}
|
|
1272
1285
|
function leaveForRestart(restarter, deps = { spawn: spawn3, exit: (code) => process.exit(code) }) {
|
|
1273
1286
|
if (restarter === "command") {
|
|
1274
1287
|
deps.spawn(process.env[RESTART_COMMAND_ENV], { shell: true, detached: true, stdio: "ignore", env: process.env }).unref();
|
|
@@ -1862,6 +1875,31 @@ var RelayMutationAdmission = class {
|
|
|
1862
1875
|
}
|
|
1863
1876
|
};
|
|
1864
1877
|
|
|
1878
|
+
// src/commands/runner/runner-work-observation.ts
|
|
1879
|
+
function observedRunnerWork(observation) {
|
|
1880
|
+
const sessions = observation.sessions;
|
|
1881
|
+
const work = observation.work;
|
|
1882
|
+
if (!Array.isArray(sessions) || !work || typeof work !== "object") return ["runner-work-observation-unavailable"];
|
|
1883
|
+
const codingTurns = typeof work.codingTurns === "number" && Number.isInteger(work.codingTurns) && work.codingTurns >= 0 ? work.codingTurns : null;
|
|
1884
|
+
const localRuns = typeof work.localRuns === "number" && Number.isInteger(work.localRuns) && work.localRuns >= 0 ? work.localRuns : null;
|
|
1885
|
+
const detail = Array.isArray(work.detail) && work.detail.every((item) => typeof item === "string") ? work.detail : null;
|
|
1886
|
+
if (codingTurns === null || localRuns === null || detail === null) return ["runner-work-observation-unavailable"];
|
|
1887
|
+
const activeCoding = sessions.flatMap((session) => {
|
|
1888
|
+
if (session?.alive === false) return [];
|
|
1889
|
+
const id = typeof session?.sessionId === "string" && session.sessionId ? session.sessionId : "unidentified-session";
|
|
1890
|
+
const state = typeof session?.runState === "string" && session.runState ? session.runState : "unknown";
|
|
1891
|
+
if (session?.alive === true && state === "idle") return [];
|
|
1892
|
+
return [`coding session ${id} (${state})`];
|
|
1893
|
+
});
|
|
1894
|
+
const unmatchedCodingTurns = Math.max(0, codingTurns - activeCoding.length);
|
|
1895
|
+
const localDetail = detail.slice(codingTurns);
|
|
1896
|
+
return [
|
|
1897
|
+
...activeCoding,
|
|
1898
|
+
...Array.from({ length: unmatchedCodingTurns }, (_v, i) => `coding-turn-unmatched-${i + 1}`),
|
|
1899
|
+
...Array.from({ length: localRuns }, (_v, i) => localDetail[i] || `local-run-${i + 1}`)
|
|
1900
|
+
];
|
|
1901
|
+
}
|
|
1902
|
+
|
|
1865
1903
|
// src/commands/runner/relay.ts
|
|
1866
1904
|
var MEMBERSHIP_SYNC_PATH = "/pags/membership/sync";
|
|
1867
1905
|
var CLI_CONTROL_PATHS = /* @__PURE__ */ new Set([MEMBERSHIP_SYNC_PATH, RUNNER_UPDATE_PATH]);
|
|
@@ -1967,14 +2005,7 @@ async function connectViaRelay(instanceIds, localUrl, runnerToken, opts, force =
|
|
|
1967
2005
|
}),
|
|
1968
2006
|
requestRunner("GET", "/health", { url: localUrl, token: runnerToken, instanceId: instanceIds[0] })
|
|
1969
2007
|
]);
|
|
1970
|
-
const
|
|
1971
|
-
const sessions = sessionsResult.status === "fulfilled" ? sessionsResult.value : { sessions: [] };
|
|
1972
|
-
const health = healthResult.status === "fulfilled" ? healthResult.value : {};
|
|
1973
|
-
const activeCoding = (sessions.sessions ?? []).filter((s) => s.alive !== false && (s.alive !== true || !["idle", "thinking", "responding"].includes(s.runState ?? "") || s.runState !== "idle")).map((s) => s.sessionId || "coding-session-state-unavailable");
|
|
1974
|
-
const localRuns = health.work?.localRuns ?? 0;
|
|
1975
|
-
const codingTurns = health.work?.codingTurns ?? 0;
|
|
1976
|
-
const unmatchedCodingTurns = Math.max(0, codingTurns - activeCoding.length);
|
|
1977
|
-
const localDetail = health.work?.detail?.slice(codingTurns) ?? [];
|
|
2008
|
+
const busy = sessionsResult.status === "fulfilled" && healthResult.status === "fulfilled" ? observedRunnerWork({ sessions: sessionsResult.value.sessions, work: healthResult.value.work }) : ["runner-work-observation-unavailable"];
|
|
1978
2009
|
return {
|
|
1979
2010
|
current: CLI_VERSION,
|
|
1980
2011
|
latest: await latestPublishedVersion(),
|
|
@@ -1982,14 +2013,14 @@ async function connectViaRelay(instanceIds, localUrl, runnerToken, opts, force =
|
|
|
1982
2013
|
restarter: restarterFrom(process.env),
|
|
1983
2014
|
// A restart ends browser/application work too. Give these durable names to the same
|
|
1984
2015
|
// planner so neither manual nor automatic update can mistake them for an idle machine.
|
|
1985
|
-
busy
|
|
2016
|
+
busy
|
|
1986
2017
|
};
|
|
1987
2018
|
};
|
|
1988
2019
|
let updateInstallInFlight = false;
|
|
1989
2020
|
let installedPendingRestart = null;
|
|
1990
2021
|
const relayMutationAdmission = new RelayMutationAdmission();
|
|
1991
|
-
const installAndRestart = async (plan, automatic = false) => {
|
|
1992
|
-
if (updateInstallInFlight) return false;
|
|
2022
|
+
const installAndRestart = async (plan, automatic = false, lease) => {
|
|
2023
|
+
if (updateInstallInFlight || lease && !deferredUpdateLeaseActive(lease)) return false;
|
|
1993
2024
|
updateInstallInFlight = true;
|
|
1994
2025
|
if (automatic && (!await refreshAutomaticPolicy() || !autoUpdatePolicy.autoUpdate)) {
|
|
1995
2026
|
updateInstallInFlight = false;
|
|
@@ -2005,9 +2036,14 @@ async function connectViaRelay(instanceIds, localUrl, runnerToken, opts, force =
|
|
|
2005
2036
|
throw error;
|
|
2006
2037
|
}
|
|
2007
2038
|
}
|
|
2039
|
+
await relayMutationAdmission.drain();
|
|
2040
|
+
await new Promise((resolve5) => setTimeout(resolve5, 500));
|
|
2041
|
+
if (lease && !deferredUpdateLeaseActive(lease)) {
|
|
2042
|
+
relayMutationAdmission.resume();
|
|
2043
|
+
updateInstallInFlight = false;
|
|
2044
|
+
return false;
|
|
2045
|
+
}
|
|
2008
2046
|
if (automatic) {
|
|
2009
|
-
await relayMutationAdmission.drain();
|
|
2010
|
-
await new Promise((resolve5) => setTimeout(resolve5, 500));
|
|
2011
2047
|
if (!await refreshAutomaticPolicy() || !autoUpdatePolicy.autoUpdate) {
|
|
2012
2048
|
relayMutationAdmission.resume();
|
|
2013
2049
|
updateInstallInFlight = false;
|
|
@@ -2022,15 +2058,22 @@ async function connectViaRelay(instanceIds, localUrl, runnerToken, opts, force =
|
|
|
2022
2058
|
}
|
|
2023
2059
|
writeLine(`Installed ${plan.latest} \u2014 restarting; every agent re-attaches on the way back up.`);
|
|
2024
2060
|
for (const id of [...attached.keys()]) detach(id);
|
|
2025
|
-
leaveForRestart(plan.restarter);
|
|
2026
2061
|
return true;
|
|
2027
2062
|
};
|
|
2028
2063
|
let updateWaiting = false;
|
|
2029
|
-
|
|
2030
|
-
|
|
2064
|
+
let deferredManualLease = null;
|
|
2065
|
+
const updateWhenIdle = (automatic = false, lease) => {
|
|
2066
|
+
if (updateWaiting || !automatic && !lease) return false;
|
|
2031
2067
|
updateWaiting = true;
|
|
2032
|
-
|
|
2068
|
+
if (!automatic) deferredManualLease = lease;
|
|
2069
|
+
const until = automatic ? Date.now() + 60 * 6e4 : lease.expiresAt;
|
|
2033
2070
|
const tick = () => setTimeout(async () => {
|
|
2071
|
+
if (!automatic && (!deferredManualLease || !deferredUpdateLeaseActive(deferredManualLease))) {
|
|
2072
|
+
writeLine("runner_update: the deferred operation lease expired; no delayed restart was performed.");
|
|
2073
|
+
deferredManualLease = null;
|
|
2074
|
+
updateWaiting = false;
|
|
2075
|
+
return;
|
|
2076
|
+
}
|
|
2034
2077
|
if (automatic && (!await refreshAutomaticPolicy() || !autoUpdatePolicy.autoUpdate)) {
|
|
2035
2078
|
saveAutoUpdateStatus("offline", { reason: "The owner policy could not be refreshed; automatic update is deferred." });
|
|
2036
2079
|
updateWaiting = false;
|
|
@@ -2043,24 +2086,30 @@ async function connectViaRelay(instanceIds, localUrl, runnerToken, opts, force =
|
|
|
2043
2086
|
return;
|
|
2044
2087
|
}
|
|
2045
2088
|
if (automatic) saveAutoUpdateStatus("running", { latestVersion: plan.latest, reason: void 0 });
|
|
2046
|
-
const installed = await installAndRestart(plan, automatic).catch((e) => {
|
|
2089
|
+
const installed = await installAndRestart(plan, automatic, automatic ? void 0 : deferredManualLease ?? void 0).catch((e) => {
|
|
2047
2090
|
if (automatic) saveAutoUpdateStatus("failure", { reason: e instanceof Error ? e.message : String(e) });
|
|
2048
2091
|
writeError(`runner_update: install failed \u2014 ${e instanceof Error ? e.message : String(e)}`);
|
|
2049
2092
|
return false;
|
|
2050
2093
|
});
|
|
2051
|
-
if (
|
|
2094
|
+
if (!installed && Date.now() < until) {
|
|
2052
2095
|
saveAutoUpdateStatus("waiting-for-idle", { latestVersion: plan.latest });
|
|
2053
2096
|
tick();
|
|
2054
|
-
} else
|
|
2097
|
+
} else {
|
|
2098
|
+
deferredManualLease = null;
|
|
2099
|
+
updateWaiting = false;
|
|
2100
|
+
if (installed) leaveForRestart(plan.restarter);
|
|
2101
|
+
}
|
|
2055
2102
|
} else if (plan.action === "wait" && Date.now() < until) {
|
|
2056
2103
|
if (automatic) saveAutoUpdateStatus("waiting-for-idle", { latestVersion: plan.latest, reason: void 0 });
|
|
2057
2104
|
tick();
|
|
2058
2105
|
} else {
|
|
2059
2106
|
if (automatic && plan.action === "refused") saveAutoUpdateStatus("unsupported", { reason: plan.reason });
|
|
2107
|
+
deferredManualLease = null;
|
|
2060
2108
|
updateWaiting = false;
|
|
2061
2109
|
}
|
|
2062
2110
|
}, 15e3).unref();
|
|
2063
2111
|
tick();
|
|
2112
|
+
return true;
|
|
2064
2113
|
};
|
|
2065
2114
|
let automaticTimer = null;
|
|
2066
2115
|
let automaticChecking = false;
|
|
@@ -2140,7 +2189,11 @@ async function connectViaRelay(instanceIds, localUrl, runnerToken, opts, force =
|
|
|
2140
2189
|
plan: planRunnerUpdate,
|
|
2141
2190
|
// This bridge is deliberately the same function used by explicit runner_update. It owns npm,
|
|
2142
2191
|
// a fresh authoritative policy, the mutation drain, final local-work observation and restart.
|
|
2143
|
-
installAndRestart,
|
|
2192
|
+
installAndRestart: async (plan, automatic) => {
|
|
2193
|
+
const installed = await installAndRestart(plan, automatic);
|
|
2194
|
+
if (installed) setTimeout(() => leaveForRestart(plan.restarter), 0).unref();
|
|
2195
|
+
return installed;
|
|
2196
|
+
},
|
|
2144
2197
|
status: saveAutoUpdateStatus
|
|
2145
2198
|
});
|
|
2146
2199
|
scheduleAutomaticUpdate = (initial = false, cancel = false) => automaticUpdateController.onPolicy(cancel ? "cancel" : initial ? "start" : "keep");
|
|
@@ -2150,15 +2203,18 @@ async function connectViaRelay(instanceIds, localUrl, runnerToken, opts, force =
|
|
|
2150
2203
|
const plan = planRunnerUpdate(await updateFacts());
|
|
2151
2204
|
if (dryRun || plan.action === "up-to-date" || plan.action === "refused") return { status: 200, result: { ...plan, dryRun } };
|
|
2152
2205
|
if (plan.action === "wait") {
|
|
2153
|
-
|
|
2154
|
-
return { status:
|
|
2206
|
+
const lease = deferredUpdateLease(body);
|
|
2207
|
+
if (!lease) return { status: 400, result: { error: "A busy runner_update needs a current operation lease; no delayed restart was scheduled." } };
|
|
2208
|
+
if (!updateWhenIdle(false, lease)) return { status: 409, result: { error: "A runner update is already waiting for active work to finish." } };
|
|
2209
|
+
return { status: 200, result: { ...plan, operationId: lease.operationId, deferredUntil: lease.expiresAt, detail: "This operation may restart only before its recorded lease expires; no delayed restart survives an expired or terminal operation." } };
|
|
2155
2210
|
}
|
|
2156
2211
|
try {
|
|
2157
|
-
if (!await
|
|
2212
|
+
if (!await installAndRestart(plan)) return { status: 409, result: { error: "A runner update is already installing or restarting." } };
|
|
2158
2213
|
} catch (e) {
|
|
2159
2214
|
return { status: 500, result: { error: `npm could not install ${plan.latest}: ${e instanceof Error ? e.message : String(e)}` } };
|
|
2160
2215
|
}
|
|
2161
2216
|
const supervisor = supervisorNote(plan.restarter);
|
|
2217
|
+
setTimeout(() => leaveForRestart(plan.restarter), 25).unref();
|
|
2162
2218
|
return { status: 200, result: { action: "restarting", current: plan.current, latest: plan.latest, restartedBy: plan.restarter, ...supervisor ? { supervisor } : {} } };
|
|
2163
2219
|
};
|
|
2164
2220
|
const answerControl = async (path, body) => {
|