local-operator-ui 0.29.12 → 0.30.1
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/out/main/index.js +1959 -92
- package/out/preload/index.js +4 -0
- package/out/renderer/assets/{_basePickBy-1H7gCAbJ.js → _basePickBy-Bxj6z8Wi.js} +1 -1
- package/out/renderer/assets/{_baseUniq-9lNuDyr9.js → _baseUniq-CRJuWtvs.js} +1 -1
- package/out/renderer/assets/{agent-details-page-DljI0xn2.js → agent-details-page-DRjUMEUI.js} +1 -1
- package/out/renderer/assets/{agent-hub-page-BrDV_UN_.js → agent-hub-page-BMCNLFYk.js} +1 -1
- package/out/renderer/assets/{agents-page-DwxRQf7x.js → agents-page-D4pQP4dR.js} +2 -2
- package/out/renderer/assets/{arc-Bo4fsGBf.js → arc-W5w8I8BO.js} +1 -1
- package/out/renderer/assets/{architectureDiagram-IEHRJDOE-CFazObUZ.js → architectureDiagram-IEHRJDOE-uGd1cdqb.js} +1 -1
- package/out/renderer/assets/{blockDiagram-JOT3LUYC-Bg72Epup.js → blockDiagram-JOT3LUYC-D1Eg2vE0.js} +1 -1
- package/out/renderer/assets/browser-page-CZ3Z-PgY.js +1 -0
- package/out/renderer/assets/{browser-webauthn-prompt-Bp6ZNrfE.js → browser-webauthn-prompt-C1cS8dj0.js} +1 -1
- package/out/renderer/assets/{c4Diagram-VJAJSXHY-BGeUxHWC.js → c4Diagram-VJAJSXHY-Me-bZTC4.js} +1 -1
- package/out/renderer/assets/channel-CYsqm6Ws.js +1 -0
- package/out/renderer/assets/{chunk-4BMEZGHF-DzG5AABJ.js → chunk-4BMEZGHF-CEIGhpCi.js} +1 -1
- package/out/renderer/assets/{chunk-A2AXSNBT-BDE1k_Sd.js → chunk-A2AXSNBT-GF1F80gh.js} +1 -1
- package/out/renderer/assets/{chunk-AEK57VVT-Bnqy1REO.js → chunk-AEK57VVT-DtLnbbUV.js} +1 -1
- package/out/renderer/assets/{chunk-D6G4REZN-OJwou3dn.js → chunk-D6G4REZN-B1DRTHm0.js} +1 -1
- package/out/renderer/assets/{chunk-RZ5BOZE2-BBpDVXLp.js → chunk-RZ5BOZE2-D6h9Tibd.js} +1 -1
- package/out/renderer/assets/{chunk-XZIHB7SX-HO33Rcol.js → chunk-XZIHB7SX-DO5kHk_V.js} +1 -1
- package/out/renderer/assets/classDiagram-GIVACNV2-ChJdbdZp.js +1 -0
- package/out/renderer/assets/classDiagram-v2-COTLJTTW-ChJdbdZp.js +1 -0
- package/out/renderer/assets/clone-C6PHt_85.js +1 -0
- package/out/renderer/assets/{compact-pagination-YLr5UxEq.js → compact-pagination-DvIFPVVF.js} +1 -1
- package/out/renderer/assets/{dagre-OKDRZEBW-DaKzvkro.js → dagre-OKDRZEBW-u5fIdPgH.js} +1 -1
- package/out/renderer/assets/{diagram-SSKATNLV-BJ85p8MH.js → diagram-SSKATNLV-BX2uYodZ.js} +1 -1
- package/out/renderer/assets/{diagram-VNBRO52H-BDylL4pB.js → diagram-VNBRO52H-CjnNNdUv.js} +1 -1
- package/out/renderer/assets/{erDiagram-Q7BY3M3F-Bmuo6n4L.js → erDiagram-Q7BY3M3F-qtGVvLUM.js} +1 -1
- package/out/renderer/assets/{flowDiagram-4HSFHLVR-CxswEMEz.js → flowDiagram-4HSFHLVR-DQvp9JGV.js} +1 -1
- package/out/renderer/assets/{ganttDiagram-APWFNJXF-CXUPGd8I.js → ganttDiagram-APWFNJXF-Ci1ah8CY.js} +1 -1
- package/out/renderer/assets/{gitGraphDiagram-7IBYFJ6S-By5CUo46.js → gitGraphDiagram-7IBYFJ6S-wJbkC8Is.js} +1 -1
- package/out/renderer/assets/{graph-CMfReLAi.js → graph-DJopaDTn.js} +1 -1
- package/out/renderer/assets/icon-Bld1dwLh.css +1 -0
- package/out/renderer/assets/{index-CbVSom_R.js → index-CnT3YOgO.js} +378 -373
- package/out/renderer/assets/{index-COoXd8RY.js → index-CxqFXiU9.js} +1 -1
- package/out/renderer/assets/{index-CpOPM-iW.js → index-DnCfIkzT.js} +1 -1
- package/out/renderer/assets/{infoDiagram-PH2N3AL5-BWOjcWnS.js → infoDiagram-PH2N3AL5-B3U7DI90.js} +1 -1
- package/out/renderer/assets/{installer-BfhAHY4r.js → installer-B8Nn8EOm.js} +1 -1
- package/out/renderer/assets/{journeyDiagram-U35MCT3I-B-x8rfW8.js → journeyDiagram-U35MCT3I-DScIXrsc.js} +1 -1
- package/out/renderer/assets/{kanban-definition-NDS4AKOZ-v7_cq85Q.js → kanban-definition-NDS4AKOZ-44QsoV35.js} +1 -1
- package/out/renderer/assets/{layout-DD4W9deB.js → layout-C2XO3rbH.js} +1 -1
- package/out/renderer/assets/{legacy-agents-page-Ciisz9T5.js → legacy-agents-page-DzvJsW6a.js} +9 -14
- package/out/renderer/assets/{mermaid.core-DMA_yzhO.js → mermaid.core-CedM0RYf.js} +19 -19
- package/out/renderer/assets/{mindmap-definition-ALO5MXBD-BO-1wp5Z.js → mindmap-definition-ALO5MXBD-Cjvw-N5z.js} +1 -1
- package/out/renderer/assets/{page-header-kampE4Cn.js → page-header-CpVC9jUn.js} +1 -1
- package/out/renderer/assets/{parseISO-fIBQPPHT.js → parseISO-D0NosKnD.js} +1 -1
- package/out/renderer/assets/{pieDiagram-IB7DONF6-SvGNVrTF.js → pieDiagram-IB7DONF6-BqCKCsJW.js} +1 -1
- package/out/renderer/assets/{quadrantDiagram-7GDLP6J5-BQ9fNSZT.js → quadrantDiagram-7GDLP6J5-Pojkyhrm.js} +1 -1
- package/out/renderer/assets/{radar-MK3ICKWK-BdQH-zQG.js → radar-MK3ICKWK-Bx4TYIXP.js} +1 -1
- package/out/renderer/assets/{radient-auth-buttons-BMIRfsdU.js → radient-auth-buttons-D_SoNQJd.js} +1 -1
- package/out/renderer/assets/{requirementDiagram-KVF5MWMF-B5b6b0Cc.js → requirementDiagram-KVF5MWMF-DUkbReMG.js} +1 -1
- package/out/renderer/assets/{sankeyDiagram-QLVOVGJD-DBrSxuPt.js → sankeyDiagram-QLVOVGJD-BUGhR_2u.js} +1 -1
- package/out/renderer/assets/{schedules-page-BiLy5_Sv.js → schedules-page-9pqWDmrK.js} +1 -1
- package/out/renderer/assets/{sequenceDiagram-X6HHIX6F-CQ7cg-pc.js → sequenceDiagram-X6HHIX6F-B0ZGPAMn.js} +1 -1
- package/out/renderer/assets/{settings-page-Bt7hCm86.js → settings-page-CnL9a7Fr.js} +1 -1
- package/out/renderer/assets/{stateDiagram-DGXRK772-C9ED7Xhx.js → stateDiagram-DGXRK772-Cg6i1sjE.js} +1 -1
- package/out/renderer/assets/stateDiagram-v2-YXO3MK2T-SfqhHCpy.js +1 -0
- package/out/renderer/assets/{timeline-definition-BDJGKUSR-DKvZ80yG.js → timeline-definition-BDJGKUSR-8RQ5e7vN.js} +1 -1
- package/out/renderer/assets/{use-agent-like-mutation-C_Y-w2IJ.js → use-agent-like-mutation-anGek86t.js} +1 -1
- package/out/renderer/assets/{xychartDiagram-VJFVF3MP-peQEFG2j.js → xychartDiagram-VJFVF3MP-DgYi9eSD.js} +1 -1
- package/out/renderer/index.html +3 -3
- package/out/renderer/installer.html +3 -3
- package/package.json +3 -2
- package/out/renderer/assets/browser-page-NToP8sAi.js +0 -1
- package/out/renderer/assets/channel-M9BWKnVc.js +0 -1
- package/out/renderer/assets/classDiagram-GIVACNV2-C2Z-Z1K9.js +0 -1
- package/out/renderer/assets/classDiagram-v2-COTLJTTW-C2Z-Z1K9.js +0 -1
- package/out/renderer/assets/clone-StYPNe3g.js +0 -1
- package/out/renderer/assets/icon-DGoQRttI.css +0 -1
- package/out/renderer/assets/stateDiagram-v2-YXO3MK2T-DNMp6of4.js +0 -1
- /package/out/renderer/assets/{icon-2Q4AuVUW.js → icon-BrElBHST.js} +0 -0
package/out/main/index.js
CHANGED
|
@@ -4240,23 +4240,31 @@ function resolveGlobalInstallPlan(input) {
|
|
|
4240
4240
|
* Managed - what the app is about to do TO THEM. This arm used to tell the
|
|
4241
4241
|
* user to go to a terminal on the one path where the app runs the command
|
|
4242
4242
|
* itself, which no surface rendered (reviews D7, U7), and the offer named
|
|
4243
|
-
* no cost at all before the click (review U3).
|
|
4244
|
-
*
|
|
4245
|
-
*
|
|
4243
|
+
* no cost at all before the click (review U3). It named the restart as its
|
|
4244
|
+
* cost for the same reason - it was the one destructive thing this path
|
|
4245
|
+
* did. It no longer is: on the generation layout the install lands beside
|
|
4246
|
+
* the running build and the server keeps serving the build it loaded until
|
|
4247
|
+
* its own next idle, so the cost is the install and the wait, and no
|
|
4248
|
+
* sentence here may describe a turn being dropped.
|
|
4246
4249
|
*
|
|
4247
4250
|
* Source build - the same disclosure with the one difference that matters:
|
|
4248
|
-
*
|
|
4249
|
-
*
|
|
4251
|
+
* this route DOES rewrite a tree a live runtime is reading, so its sentence
|
|
4252
|
+
* keeps saying so. What it adds is the half it was silent about: the app
|
|
4253
|
+
* now waits for the fleet to drain before it starts
|
|
4254
|
+
* (`drainFleetForUpdate`), which is the operator's own rule - nothing may
|
|
4255
|
+
* kill runtimes en masse - applied to the one route that can.
|
|
4250
4256
|
*
|
|
4251
4257
|
* Legacy - WHY the app will not press the button, which is what the user
|
|
4252
4258
|
* is choosing between (reviews U8, N1). It lived only in the mono Details
|
|
4253
|
-
* line, trailing a resolved path and a classification.
|
|
4259
|
+
* line, trailing a resolved path and a classification. Its warning is
|
|
4260
|
+
* about the OLD INSTALLER the reader would run themselves, not about
|
|
4261
|
+
* anything this app does, which is why it still says a turn can be lost.
|
|
4254
4262
|
*
|
|
4255
4263
|
* NEITHER ENDS IN A COLON: the well below is visually distinct in both
|
|
4256
4264
|
* panels, and in the by-hand panel the next line was the version sentence,
|
|
4257
4265
|
* so the promise landed on the wrong line (review D5).
|
|
4258
4266
|
*/
|
|
4259
|
-
remedy: managed ? "The app
|
|
4267
|
+
remedy: managed ? "The app installs the new build beside the one in use and leaves the server you are using on the build it loaded, so nothing in flight is cut off. The server moves onto the new build when it is next idle." : sourceRebuildRoute ? "Rebuilds this checkout with `lop-update`. The app waits for the turns running on this machine to finish first, and the rebuild then reinstalls this install in place - so a turn started while it runs can still be interrupted - and it can take up to half an hour. This install keeps reporting the checkout's version, not the release the app offered." : "This install predates the non-disruptive installer, so update it once from your terminal; that updater rewrites the shared environment in place, which can interrupt sessions mid-turn. The app manages updates after that.",
|
|
4260
4268
|
detail: `${detail}${managed ? provenance : sourceRebuildRoute ? " source build of this machine's checkout; an in-place rebuild." : provenance}`,
|
|
4261
4269
|
sourceBuild,
|
|
4262
4270
|
managedRoute: managed ? "entry-point" : sourceRebuildRoute ? "source-build" : null,
|
|
@@ -4402,14 +4410,35 @@ function driftInstallReading(input) {
|
|
|
4402
4410
|
return { version: input.planVersion, source: "unknown" };
|
|
4403
4411
|
return { version: input.planVersion, source: "serving-install" };
|
|
4404
4412
|
}
|
|
4405
|
-
|
|
4413
|
+
const DEGRADED_LIVENESS_SOURCE = "liveness";
|
|
4414
|
+
function rosterLivenessDegraded(body) {
|
|
4415
|
+
const degraded = body?.result?.degraded;
|
|
4416
|
+
return Array.isArray(degraded) && degraded.includes(DEGRADED_LIVENESS_SOURCE);
|
|
4417
|
+
}
|
|
4418
|
+
const KNOWN_LIVE_STATES = /* @__PURE__ */ new Set(["", "wedged", "busy", "attached", "idle"]);
|
|
4419
|
+
function fleetRosterFromSessions(body) {
|
|
4406
4420
|
const sessions = body?.result?.sessions;
|
|
4407
|
-
if (!Array.isArray(sessions)) return
|
|
4408
|
-
|
|
4421
|
+
if (!Array.isArray(sessions)) return null;
|
|
4422
|
+
if (rosterLivenessDegraded(body)) return null;
|
|
4423
|
+
return sessions.filter(
|
|
4409
4424
|
(row) => typeof row === "object" && row !== null
|
|
4410
|
-
)
|
|
4411
|
-
|
|
4412
|
-
|
|
4425
|
+
).map((row) => ({
|
|
4426
|
+
sessionId: typeof row.id === "string" ? row.id : "",
|
|
4427
|
+
name: typeof row.name === "string" ? row.name : "",
|
|
4428
|
+
kind: typeof row.kind === "string" ? row.kind : "",
|
|
4429
|
+
liveState: typeof row.live_state === "string" ? row.live_state : null
|
|
4430
|
+
}));
|
|
4431
|
+
}
|
|
4432
|
+
function busyRosterRows(rows) {
|
|
4433
|
+
return rows.filter((row) => row.liveState === "busy");
|
|
4434
|
+
}
|
|
4435
|
+
function servingWorkStateFromSessions(body) {
|
|
4436
|
+
const rows = fleetRosterFromSessions(body);
|
|
4437
|
+
if (rows === null) return "unknown";
|
|
4438
|
+
if (busyRosterRows(rows).length > 0) return "busy";
|
|
4439
|
+
if (rows.some(
|
|
4440
|
+
(row) => row.liveState === null || !KNOWN_LIVE_STATES.has(row.liveState)
|
|
4441
|
+
))
|
|
4413
4442
|
return "unknown";
|
|
4414
4443
|
return "idle";
|
|
4415
4444
|
}
|
|
@@ -6199,7 +6228,7 @@ async function prepareRuntime(options) {
|
|
|
6199
6228
|
await promises.rename(staging, runtime);
|
|
6200
6229
|
return { runtime, id: id2 };
|
|
6201
6230
|
}
|
|
6202
|
-
function describe$
|
|
6231
|
+
function describe$2(error) {
|
|
6203
6232
|
return error instanceof Error ? error.message : String(error);
|
|
6204
6233
|
}
|
|
6205
6234
|
function selectionIsWellFormed(root, selection) {
|
|
@@ -6251,7 +6280,7 @@ function inspectManagedSelection(options) {
|
|
|
6251
6280
|
realDirectory(path2);
|
|
6252
6281
|
} catch (error) {
|
|
6253
6282
|
return missing(
|
|
6254
|
-
`The selected ${what} is not there any more (${path2}): ${describe$
|
|
6283
|
+
`The selected ${what} is not there any more (${path2}): ${describe$2(error)}`
|
|
6255
6284
|
);
|
|
6256
6285
|
}
|
|
6257
6286
|
}
|
|
@@ -6265,7 +6294,7 @@ function inspectManagedSelection(options) {
|
|
|
6265
6294
|
);
|
|
6266
6295
|
} catch (error) {
|
|
6267
6296
|
return missing(
|
|
6268
|
-
`The selected environment is not complete (${selection.venv}): ${describe$
|
|
6297
|
+
`The selected environment is not complete (${selection.venv}): ${describe$2(error)}`
|
|
6269
6298
|
);
|
|
6270
6299
|
}
|
|
6271
6300
|
if (!fs.existsSync(path$1.join(selection.venv, "bin", "local-operator")))
|
|
@@ -6279,7 +6308,7 @@ function inspectManagedSelection(options) {
|
|
|
6279
6308
|
)?.[1]?.trim();
|
|
6280
6309
|
} catch (error) {
|
|
6281
6310
|
return missing(
|
|
6282
|
-
`The selected environment has no readable pyvenv.cfg (${selection.venv}): ${describe$
|
|
6311
|
+
`The selected environment has no readable pyvenv.cfg (${selection.venv}): ${describe$2(error)}`
|
|
6283
6312
|
);
|
|
6284
6313
|
}
|
|
6285
6314
|
if (home !== path$1.join(selection.runtime, "bin"))
|
|
@@ -6291,7 +6320,7 @@ function inspectManagedSelection(options) {
|
|
|
6291
6320
|
identity = runtimeIdentity(selection.runtime, options.arch);
|
|
6292
6321
|
} catch (error) {
|
|
6293
6322
|
return missing(
|
|
6294
|
-
`The selected Python runtime could not be read (${selection.runtime}): ${describe$
|
|
6323
|
+
`The selected Python runtime could not be read (${selection.runtime}): ${describe$2(error)}`
|
|
6295
6324
|
);
|
|
6296
6325
|
}
|
|
6297
6326
|
if (identity !== selection.runtimeId) {
|
|
@@ -6679,7 +6708,7 @@ class ProbeTimeout extends Error {
|
|
|
6679
6708
|
this.name = "ProbeTimeout";
|
|
6680
6709
|
}
|
|
6681
6710
|
}
|
|
6682
|
-
const describe = (error) => error instanceof Error ? error.message : String(error);
|
|
6711
|
+
const describe$1 = (error) => error instanceof Error ? error.message : String(error);
|
|
6683
6712
|
function runBounded(command, args, env, bounds) {
|
|
6684
6713
|
return new Promise((resolve, reject) => {
|
|
6685
6714
|
const child = node_child_process.spawn(command, args, {
|
|
@@ -6857,7 +6886,7 @@ async function ownedServeLaunch(interpreters, port, env, platform = process.plat
|
|
|
6857
6886
|
bounds
|
|
6858
6887
|
);
|
|
6859
6888
|
} catch (error) {
|
|
6860
|
-
rejected.push(`${interpreter}: ${describe(error)}`);
|
|
6889
|
+
rejected.push(`${interpreter}: ${describe$1(error)}`);
|
|
6861
6890
|
}
|
|
6862
6891
|
}
|
|
6863
6892
|
return null;
|
|
@@ -7324,6 +7353,18 @@ class BackendServiceManager {
|
|
|
7324
7353
|
// Flag to track when the app is being closed
|
|
7325
7354
|
isAutoUpdating = false;
|
|
7326
7355
|
// Flag to track when an autoupdate is in progress
|
|
7356
|
+
/**
|
|
7357
|
+
* WHY the fleet roster last came back unreadable, when the server answered.
|
|
7358
|
+
*
|
|
7359
|
+
* A dead socket and a 401 look the same to `servingWorkState` - both are not
|
|
7360
|
+
* evidence about work, so both are `unknown` and both hold a restart - but they
|
|
7361
|
+
* are different next steps for a READER, and the update path's refusal says
|
|
7362
|
+
* which happened (QA round 1, observation b). The transport is the only party
|
|
7363
|
+
* that knows, so it records the answer here and the refusal reads it back;
|
|
7364
|
+
* guessing it from the verdict is how the two would come to disagree. Reset on
|
|
7365
|
+
* every read, so it always describes the most recent one.
|
|
7366
|
+
*/
|
|
7367
|
+
fleetReadFailure = "unreachable";
|
|
7327
7368
|
shutdownTimeoutMs = { ...SHUTDOWN_TIMEOUT_DEFAULTS };
|
|
7328
7369
|
// Configurable timeouts for different shutdown scenarios
|
|
7329
7370
|
/**
|
|
@@ -9150,6 +9191,10 @@ class BackendServiceManager {
|
|
|
9150
9191
|
* A transport that does not answer, and a non-200, are both `unknown` - which
|
|
9151
9192
|
* the decision treats as a reason to WAIT. A read that could not be taken is
|
|
9152
9193
|
* not evidence that the machine is quiet.
|
|
9194
|
+
*
|
|
9195
|
+
* A LISTING whose own liveness read failed is `unknown` for the same reason and
|
|
9196
|
+
* by the same route: `fleetRosterFromSessions` declines a degraded roster and
|
|
9197
|
+
* answers null, which this reduces to `unknown` (review round 1, B1 = QA Q-1).
|
|
9153
9198
|
*/
|
|
9154
9199
|
async servingWorkState() {
|
|
9155
9200
|
try {
|
|
@@ -9159,12 +9204,63 @@ class BackendServiceManager {
|
|
|
9159
9204
|
// first page is still work in flight, so the read asks for the lot.
|
|
9160
9205
|
limit: 500
|
|
9161
9206
|
});
|
|
9207
|
+
this.noteFleetReadAnswer(response.status);
|
|
9162
9208
|
if (response.status !== 200) return "unknown";
|
|
9163
9209
|
return servingWorkStateFromSessions(response.body);
|
|
9164
9210
|
} catch {
|
|
9211
|
+
this.fleetReadFailure = "unreachable";
|
|
9165
9212
|
return "unknown";
|
|
9166
9213
|
}
|
|
9167
9214
|
}
|
|
9215
|
+
/**
|
|
9216
|
+
* Record what a `sessions.list` answer says about the app's own access.
|
|
9217
|
+
*
|
|
9218
|
+
* 401/403 is the one non-200 that is about the CREDENTIAL rather than about the
|
|
9219
|
+
* fleet: the server is up and answering, and it is refusing this app's token.
|
|
9220
|
+
* Everything else - another status, or no answer at all - is `unreachable`,
|
|
9221
|
+
* which is the arm whose remedy (try again once the server answers) is true.
|
|
9222
|
+
*/
|
|
9223
|
+
noteFleetReadAnswer(status2) {
|
|
9224
|
+
this.fleetReadFailure = status2 === 401 || status2 === 403 ? "refused-credentials" : "unreachable";
|
|
9225
|
+
}
|
|
9226
|
+
/**
|
|
9227
|
+
* Why the last fleet read could not be taken: `servingWorkState`'s own reason.
|
|
9228
|
+
*
|
|
9229
|
+
* Read by the update path only when a refusal is being composed, and only on
|
|
9230
|
+
* the `unknown` arm - see `FleetDrainOutcome.credentialsRefused`.
|
|
9231
|
+
*/
|
|
9232
|
+
fleetReadFailureReason() {
|
|
9233
|
+
return this.fleetReadFailure;
|
|
9234
|
+
}
|
|
9235
|
+
/**
|
|
9236
|
+
* The session roster itself: the same read as `servingWorkState`, with the
|
|
9237
|
+
* rows kept rather than reduced to one verdict.
|
|
9238
|
+
*
|
|
9239
|
+
* The update path's fleet gate needs the rows for two jobs a verdict cannot
|
|
9240
|
+
* do: naming the sessions it waited for in a refusal, and taking the before
|
|
9241
|
+
* and after snapshots that tell it which runtimes a restart displaced
|
|
9242
|
+
* (`backend/fleet-drain.ts`). Both callers read ONE route, and the row shape
|
|
9243
|
+
* and the busy spelling come from the same module, so there is no second
|
|
9244
|
+
* reading of what "busy" means.
|
|
9245
|
+
*
|
|
9246
|
+
* Null rather than an empty array when the route did not answer: an empty
|
|
9247
|
+
* roster is a machine with no sessions, which is a different fact from a read
|
|
9248
|
+
* that could not be taken.
|
|
9249
|
+
*/
|
|
9250
|
+
async servingSessionFleet() {
|
|
9251
|
+
try {
|
|
9252
|
+
const response = await this.requestDesktop({
|
|
9253
|
+
op: "sessions.list",
|
|
9254
|
+
limit: 500
|
|
9255
|
+
});
|
|
9256
|
+
this.noteFleetReadAnswer(response.status);
|
|
9257
|
+
if (response.status !== 200) return null;
|
|
9258
|
+
return fleetRosterFromSessions(response.body);
|
|
9259
|
+
} catch {
|
|
9260
|
+
this.fleetReadFailure = "unreachable";
|
|
9261
|
+
return null;
|
|
9262
|
+
}
|
|
9263
|
+
}
|
|
9168
9264
|
/**
|
|
9169
9265
|
* The address this app is talking to RIGHT NOW.
|
|
9170
9266
|
*
|
|
@@ -10219,7 +10315,17 @@ const METHODS$1 = [
|
|
|
10219
10315
|
"owner_finish",
|
|
10220
10316
|
"owner_retain",
|
|
10221
10317
|
"owner_release",
|
|
10222
|
-
...CONSOLE_METHODS
|
|
10318
|
+
...CONSOLE_METHODS,
|
|
10319
|
+
// File transfer, appended in the Python source's order so the two lists stay
|
|
10320
|
+
// comparable by eye (design §6.1, §6.5). Both halves are NOT symmetric on the
|
|
10321
|
+
// extension, and the reason is a measurement rather than a preference — see
|
|
10322
|
+
// `EXTENSION_CANNOT_SERVE` in `browser_bridge/protocol.py`: no extension build
|
|
10323
|
+
// can serve `download`, because Chrome refuses an extension every CDP primitive
|
|
10324
|
+
// that could put a file where the harness chose (measured 2026-09-18, Chrome
|
|
10325
|
+
// 153), while `upload` rides `DOM.setFileInputFiles` on the session it already
|
|
10326
|
+
// holds. THIS host serves both.
|
|
10327
|
+
"download",
|
|
10328
|
+
"upload"
|
|
10223
10329
|
];
|
|
10224
10330
|
const METHOD_SET = new Set(METHODS$1);
|
|
10225
10331
|
function isMethod(value) {
|
|
@@ -10243,6 +10349,15 @@ const ERROR_CODES = [
|
|
|
10243
10349
|
"proto_mismatch",
|
|
10244
10350
|
"owner_refused",
|
|
10245
10351
|
"extension_unresponsive",
|
|
10352
|
+
// Present for MIRROR PARITY with `protocol.py`'s `ErrorCode`, and emitted by the
|
|
10353
|
+
// DAEMON, never by this host (§6.2). It has to be in this list for the reason
|
|
10354
|
+
// `rpc.ts` narrows an unknown code to `internal`: an already-released session
|
|
10355
|
+
// validates `ErrorDetail.code` against the Python enum, so a frame carrying a
|
|
10356
|
+
// code it does not know is DROPPED — which is worse than a wrong answer, because
|
|
10357
|
+
// the caller waits out its whole budget. The daemon is on the safe side of that
|
|
10358
|
+
// direction (daemon -> session), a browser host is not, which is exactly why a
|
|
10359
|
+
// policy refusal travels as an `ok: true` result here.
|
|
10360
|
+
"capability_unsupported",
|
|
10246
10361
|
"internal",
|
|
10247
10362
|
// The console namespace's own additions (design 10.6). Spread rather than copied
|
|
10248
10363
|
// for the same reason METHODS is: this list is what `rpc.ts` narrows a raised
|
|
@@ -10250,6 +10365,7 @@ const ERROR_CODES = [
|
|
|
10250
10365
|
// a command that hangs rather than one that fails.
|
|
10251
10366
|
...CONSOLE_ERROR_CODES
|
|
10252
10367
|
];
|
|
10368
|
+
const HOST_CAPABILITIES = ["download", "upload"];
|
|
10253
10369
|
const RUN_DIRNAME = path$1.join("run", "ui-browser");
|
|
10254
10370
|
const STATE_FILENAME = "host.json";
|
|
10255
10371
|
const STATE_DIR_MODE = 448;
|
|
@@ -10319,6 +10435,10 @@ class BrowserStateWriter {
|
|
|
10319
10435
|
host: "ui",
|
|
10320
10436
|
app_version: this.appVersion,
|
|
10321
10437
|
profile_dir: facts.profileDir,
|
|
10438
|
+
// Advertised from the ONE list the `/health` route also reads (§6.3), so the
|
|
10439
|
+
// record a session reads and the probe Python acquits a stale record with
|
|
10440
|
+
// cannot name different capabilities.
|
|
10441
|
+
capabilities: [...HOST_CAPABILITIES],
|
|
10322
10442
|
tabs: facts.tabs,
|
|
10323
10443
|
agent_tabs: facts.agentTabs,
|
|
10324
10444
|
console: facts.console ?? false,
|
|
@@ -10651,7 +10771,7 @@ const FIXED = /* @__PURE__ */ new Map([
|
|
|
10651
10771
|
["f11", "\x1B[23~"],
|
|
10652
10772
|
["f12", "\x1B[24~"]
|
|
10653
10773
|
]);
|
|
10654
|
-
const CONTROL = /* @__PURE__ */ new Map([
|
|
10774
|
+
const CONTROL$1 = /* @__PURE__ */ new Map([
|
|
10655
10775
|
["ctrl+space", 0],
|
|
10656
10776
|
["ctrl+[", 27],
|
|
10657
10777
|
["ctrl+\\", 28],
|
|
@@ -10660,7 +10780,7 @@ const CONTROL = /* @__PURE__ */ new Map([
|
|
|
10660
10780
|
["ctrl+_", 31]
|
|
10661
10781
|
]);
|
|
10662
10782
|
for (const letter of "abcdefghijklmnopqrstuvwxyz") {
|
|
10663
|
-
CONTROL.set(`ctrl+${letter}`, letter.charCodeAt(0) - 96);
|
|
10783
|
+
CONTROL$1.set(`ctrl+${letter}`, letter.charCodeAt(0) - 96);
|
|
10664
10784
|
}
|
|
10665
10785
|
const CURSOR = /* @__PURE__ */ new Map([
|
|
10666
10786
|
["up", { normal: "\x1B[A", application: "\x1BOA" }],
|
|
@@ -10675,7 +10795,7 @@ const encodeText = (text) => ENCODER$1.encode(text);
|
|
|
10675
10795
|
function encodeNamedKey(name, modes) {
|
|
10676
10796
|
const fixed = FIXED.get(name);
|
|
10677
10797
|
if (fixed !== void 0) return encodeText(fixed);
|
|
10678
|
-
const control = CONTROL.get(name);
|
|
10798
|
+
const control = CONTROL$1.get(name);
|
|
10679
10799
|
if (control !== void 0) return Uint8Array.from([control]);
|
|
10680
10800
|
const cursor = CURSOR.get(name);
|
|
10681
10801
|
if (cursor !== void 0) {
|
|
@@ -12646,6 +12766,7 @@ const PTY_PACKAGE = "node-pty";
|
|
|
12646
12766
|
const SPAWN_HELPER_NAME = "spawn-helper";
|
|
12647
12767
|
const SPAWN_HELPER_MODE = 493;
|
|
12648
12768
|
const OWNER_EXECUTE = 64;
|
|
12769
|
+
const NATIVE_MODULE_NAME = "pty.node";
|
|
12649
12770
|
const ASAR_APP = /([/\\])app\.asar([/\\])/;
|
|
12650
12771
|
const ASAR_NODE_MODULES = /([/\\])node_modules\.asar([/\\])/;
|
|
12651
12772
|
function unpackedPath(path2) {
|
|
@@ -12654,8 +12775,18 @@ function unpackedPath(path2) {
|
|
|
12654
12775
|
function prebuildDir(root, target2) {
|
|
12655
12776
|
return path$1.join(root, "prebuilds", `${target2.platform}-${target2.arch}`);
|
|
12656
12777
|
}
|
|
12657
|
-
function
|
|
12658
|
-
return
|
|
12778
|
+
function forksSpawnHelper(platform) {
|
|
12779
|
+
return platform === "darwin";
|
|
12780
|
+
}
|
|
12781
|
+
function nativeDirs(root, target2) {
|
|
12782
|
+
return [
|
|
12783
|
+
path$1.join(root, "build", "Release"),
|
|
12784
|
+
path$1.join(root, "build", "Debug"),
|
|
12785
|
+
prebuildDir(root, target2)
|
|
12786
|
+
];
|
|
12787
|
+
}
|
|
12788
|
+
function spawnHelperPaths(root, target2) {
|
|
12789
|
+
return nativeDirs(root, target2).map((dir) => path$1.join(dir, SPAWN_HELPER_NAME));
|
|
12659
12790
|
}
|
|
12660
12791
|
function resolvePtyRoot() {
|
|
12661
12792
|
const requireFrom = node_module.createRequire(
|
|
@@ -12666,26 +12797,50 @@ function resolvePtyRoot() {
|
|
|
12666
12797
|
);
|
|
12667
12798
|
}
|
|
12668
12799
|
function ensureSpawnHelperExecutable(root, target2) {
|
|
12669
|
-
const
|
|
12800
|
+
const required = forksSpawnHelper(target2.platform);
|
|
12670
12801
|
try {
|
|
12671
|
-
|
|
12672
|
-
|
|
12673
|
-
|
|
12674
|
-
|
|
12675
|
-
|
|
12676
|
-
|
|
12802
|
+
const helpers = spawnHelperPaths(root, target2).filter((path2) => fs.existsSync(path2)).map((path2) => {
|
|
12803
|
+
const mode = fs.statSync(path2).mode & 511;
|
|
12804
|
+
if ((mode & OWNER_EXECUTE) !== 0) return { path: path2, mode, healed: false };
|
|
12805
|
+
fs.chmodSync(path2, SPAWN_HELPER_MODE);
|
|
12806
|
+
return { path: path2, mode, healed: true };
|
|
12807
|
+
});
|
|
12808
|
+
if (helpers.length === 0) {
|
|
12809
|
+
return {
|
|
12810
|
+
path: null,
|
|
12811
|
+
mode: null,
|
|
12812
|
+
healed: false,
|
|
12813
|
+
required,
|
|
12814
|
+
helpers,
|
|
12815
|
+
reason: required ? "no helper" : `no helper, and ${target2.platform}-${target2.arch} forks none`
|
|
12816
|
+
};
|
|
12677
12817
|
}
|
|
12678
|
-
|
|
12679
|
-
|
|
12818
|
+
const loaded = nativeDirs(root, target2).find(
|
|
12819
|
+
(dir) => fs.existsSync(path$1.join(dir, NATIVE_MODULE_NAME))
|
|
12820
|
+
);
|
|
12821
|
+
const primary = helpers.find((helper) => path$1.dirname(helper.path) === loaded) ?? helpers[0];
|
|
12822
|
+
return {
|
|
12823
|
+
path: primary.path,
|
|
12824
|
+
mode: primary.mode,
|
|
12825
|
+
healed: helpers.some((helper) => helper.healed),
|
|
12826
|
+
required,
|
|
12827
|
+
helpers
|
|
12828
|
+
};
|
|
12680
12829
|
} catch (error) {
|
|
12681
12830
|
return {
|
|
12682
|
-
path:
|
|
12831
|
+
path: null,
|
|
12683
12832
|
mode: null,
|
|
12684
12833
|
healed: false,
|
|
12834
|
+
required,
|
|
12835
|
+
helpers: [],
|
|
12685
12836
|
reason: error instanceof Error ? error.message : String(error)
|
|
12686
12837
|
};
|
|
12687
12838
|
}
|
|
12688
12839
|
}
|
|
12840
|
+
function helperStatus(report) {
|
|
12841
|
+
if (report.path !== null) return "ready";
|
|
12842
|
+
return report.required ? "without a helper" : "no helper needed on this platform";
|
|
12843
|
+
}
|
|
12689
12844
|
let cached = null;
|
|
12690
12845
|
function loadNodePty() {
|
|
12691
12846
|
if (cached) return cached;
|
|
@@ -12841,7 +12996,7 @@ async function startConsoleHost(options) {
|
|
|
12841
12996
|
log
|
|
12842
12997
|
});
|
|
12843
12998
|
log(
|
|
12844
|
-
`[console] host ready on the existing endpoint: ${registry.count()} surface(s) (node-pty ${load.helper
|
|
12999
|
+
`[console] host ready on the existing endpoint: ${registry.count()} surface(s) (node-pty ${helperStatus(load.helper)})`
|
|
12845
13000
|
);
|
|
12846
13001
|
return {
|
|
12847
13002
|
ok: true,
|
|
@@ -13309,31 +13464,6 @@ function readJson(path2) {
|
|
|
13309
13464
|
function errorData(error) {
|
|
13310
13465
|
return error instanceof BridgeCommandError ? error.data : {};
|
|
13311
13466
|
}
|
|
13312
|
-
const CDP_DEADLINE_MS = 15e3;
|
|
13313
|
-
const CDP_ATTACH_DEADLINE_MS = 5e3;
|
|
13314
|
-
const CHROME_API_DEADLINE_MS = 5e3;
|
|
13315
|
-
const SCRIPTING_DEADLINE_MS = 15e3;
|
|
13316
|
-
function deadline(op, ms, what) {
|
|
13317
|
-
return new Promise((resolve, reject) => {
|
|
13318
|
-
const timer = setTimeout(() => {
|
|
13319
|
-
reject(
|
|
13320
|
-
new BridgeCommandError("internal", `${what} did not respond within ${ms}ms`, {
|
|
13321
|
-
stalled: what
|
|
13322
|
-
})
|
|
13323
|
-
);
|
|
13324
|
-
}, ms);
|
|
13325
|
-
op.then(
|
|
13326
|
-
(value) => {
|
|
13327
|
-
clearTimeout(timer);
|
|
13328
|
-
resolve(value);
|
|
13329
|
-
},
|
|
13330
|
-
(error) => {
|
|
13331
|
-
clearTimeout(timer);
|
|
13332
|
-
reject(error);
|
|
13333
|
-
}
|
|
13334
|
-
);
|
|
13335
|
-
});
|
|
13336
|
-
}
|
|
13337
13467
|
function nextSequence(queue) {
|
|
13338
13468
|
return (queue ?? []).reduce(
|
|
13339
13469
|
(highest, entry) => Math.max(highest, entry.sequence),
|
|
@@ -13351,7 +13481,7 @@ const SCROLL_DIRECTIONS = /* @__PURE__ */ new Set([
|
|
|
13351
13481
|
function sleep(ms) {
|
|
13352
13482
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
13353
13483
|
}
|
|
13354
|
-
const WEB_CONTENTS_DEADLINE_MS =
|
|
13484
|
+
const WEB_CONTENTS_DEADLINE_MS = 15e3;
|
|
13355
13485
|
const ONCE_GRANT_TTL_MS = 10 * 6e4;
|
|
13356
13486
|
function receiptKey(origin, requester) {
|
|
13357
13487
|
return `${origin}
|
|
@@ -14342,6 +14472,30 @@ function routeDebuggerEvent(webContentsId, method, rawParams) {
|
|
|
14342
14472
|
});
|
|
14343
14473
|
}
|
|
14344
14474
|
}
|
|
14475
|
+
const CDP_DEADLINE_MS = 15e3;
|
|
14476
|
+
const CDP_ATTACH_DEADLINE_MS = 5e3;
|
|
14477
|
+
const SCRIPTING_DEADLINE_MS = 15e3;
|
|
14478
|
+
function deadline(op, ms, what) {
|
|
14479
|
+
return new Promise((resolve, reject) => {
|
|
14480
|
+
const timer = setTimeout(() => {
|
|
14481
|
+
reject(
|
|
14482
|
+
new BridgeCommandError("internal", `${what} did not respond within ${ms}ms`, {
|
|
14483
|
+
stalled: what
|
|
14484
|
+
})
|
|
14485
|
+
);
|
|
14486
|
+
}, ms);
|
|
14487
|
+
op.then(
|
|
14488
|
+
(value) => {
|
|
14489
|
+
clearTimeout(timer);
|
|
14490
|
+
resolve(value);
|
|
14491
|
+
},
|
|
14492
|
+
(error) => {
|
|
14493
|
+
clearTimeout(timer);
|
|
14494
|
+
reject(error);
|
|
14495
|
+
}
|
|
14496
|
+
);
|
|
14497
|
+
});
|
|
14498
|
+
}
|
|
14345
14499
|
const PROTOCOL_VERSION = "1.3";
|
|
14346
14500
|
const ALREADY_ATTACHED = /another debugger|already attached/i;
|
|
14347
14501
|
class CdpPool {
|
|
@@ -14623,6 +14777,743 @@ class ConsentNotifier {
|
|
|
14623
14777
|
}
|
|
14624
14778
|
}
|
|
14625
14779
|
}
|
|
14780
|
+
const DENY_EXTS = ["apk", "app", "bash", "bat", "cmd", "com", "command", "cpl", "crx", "dex", "dll", "dmg", "drv", "exe", "hta", "jar", "lnk", "msi", "msp", "mst", "ocx", "pkg", "ps1", "psm1", "reg", "scpt", "scr", "sh", "sys", "url", "vbe", "vbs", "wasm", "wsf", "zsh"];
|
|
14781
|
+
const CREDENTIAL_NAME_PATTERNS = ["id_rsa*", "id_ed25519*", "id_ecdsa*", "id_dsa*", "*.pem", "*.key", "*.p12", "*.pfx", "*.jks", "*.keystore", ".netrc", "_netrc", ".git-credentials", ".npmrc", ".pypirc", ".pgpass", ".my.cnf", ".dockercfg", ".env", ".env.*", "credentials", "credentials.json", "service-account*.json", "*.keychain", "*.keychain-db"];
|
|
14782
|
+
const CAPS = {
|
|
14783
|
+
"downloadMaxBytes": 268435456,
|
|
14784
|
+
"downloadMaxFilesPerCall": 20,
|
|
14785
|
+
"downloadTimeoutS": 120,
|
|
14786
|
+
"downloadTimeoutMaxS": 600,
|
|
14787
|
+
"uploadMaxFiles": 10
|
|
14788
|
+
};
|
|
14789
|
+
const MAX_NAME_BYTES = 200;
|
|
14790
|
+
const FALLBACK_STEM = "download";
|
|
14791
|
+
const CONTROL = /[\u0000-\u001f\u007f-\u009f]/g;
|
|
14792
|
+
const BIDI_ZERO_WIDTH = /[\u200b-\u200f\u202a-\u202e\u2066-\u2069]/g;
|
|
14793
|
+
const WINDOWS_RESERVED_STEMS = /* @__PURE__ */ new Set([
|
|
14794
|
+
"con",
|
|
14795
|
+
"prn",
|
|
14796
|
+
"aux",
|
|
14797
|
+
"nul",
|
|
14798
|
+
"com1",
|
|
14799
|
+
"com2",
|
|
14800
|
+
"com3",
|
|
14801
|
+
"com4",
|
|
14802
|
+
"com5",
|
|
14803
|
+
"com6",
|
|
14804
|
+
"com7",
|
|
14805
|
+
"com8",
|
|
14806
|
+
"com9",
|
|
14807
|
+
"lpt1",
|
|
14808
|
+
"lpt2",
|
|
14809
|
+
"lpt3",
|
|
14810
|
+
"lpt4",
|
|
14811
|
+
"lpt5",
|
|
14812
|
+
"lpt6",
|
|
14813
|
+
"lpt7",
|
|
14814
|
+
"lpt8",
|
|
14815
|
+
"lpt9"
|
|
14816
|
+
]);
|
|
14817
|
+
function bytesOf(text) {
|
|
14818
|
+
return new TextEncoder().encode(text).length;
|
|
14819
|
+
}
|
|
14820
|
+
function fallbackDigest(text) {
|
|
14821
|
+
let hash = 2166136261;
|
|
14822
|
+
for (const byte of new TextEncoder().encode(text)) {
|
|
14823
|
+
hash = Math.imul(hash ^ byte, 16777619) >>> 0;
|
|
14824
|
+
}
|
|
14825
|
+
return hash.toString(16).padStart(8, "0");
|
|
14826
|
+
}
|
|
14827
|
+
function truncateBytes(name, limit) {
|
|
14828
|
+
let head = name;
|
|
14829
|
+
let ext = "";
|
|
14830
|
+
const dot = name.lastIndexOf(".");
|
|
14831
|
+
if (dot > 0 && name.length - dot - 1 <= 12) {
|
|
14832
|
+
head = name.slice(0, dot);
|
|
14833
|
+
ext = name.slice(dot + 1);
|
|
14834
|
+
}
|
|
14835
|
+
const budget = limit - (ext ? bytesOf(ext) + 1 : 0);
|
|
14836
|
+
if (budget <= 0) return "";
|
|
14837
|
+
while (bytesOf(head) > budget && head.length > 0) {
|
|
14838
|
+
head = head.slice(0, -1);
|
|
14839
|
+
if (/[\ud800-\udbff]$/.test(head)) head = head.slice(0, -1);
|
|
14840
|
+
}
|
|
14841
|
+
return ext ? `${head}.${ext}` : head;
|
|
14842
|
+
}
|
|
14843
|
+
function fallbackName(raw, sniffedExt = "") {
|
|
14844
|
+
const stem = `${FALLBACK_STEM}-${fallbackDigest(raw)}`;
|
|
14845
|
+
return sniffedExt ? `${stem}.${sniffedExt}` : stem;
|
|
14846
|
+
}
|
|
14847
|
+
function safeName(raw, sniffedExt = "") {
|
|
14848
|
+
const parts = raw.replace(/\\/g, "/").split("/");
|
|
14849
|
+
let name = (parts[parts.length - 1] ?? "").replace(CONTROL, "").replace(BIDI_ZERO_WIDTH, "");
|
|
14850
|
+
name = name.normalize("NFC").trim().replace(/[. ]+$/, "");
|
|
14851
|
+
if (name === "" || name === "." || name === "..") return fallbackName(raw, sniffedExt);
|
|
14852
|
+
const dot = name.lastIndexOf(".");
|
|
14853
|
+
const stem = dot > 0 ? name.slice(0, dot) : name;
|
|
14854
|
+
let ext = dot > 0 ? name.slice(dot + 1) : "";
|
|
14855
|
+
if (WINDOWS_RESERVED_STEMS.has(stem.toLowerCase())) return fallbackName(raw, sniffedExt);
|
|
14856
|
+
if (sniffedExt && ext.toLowerCase() !== sniffedExt.toLowerCase()) {
|
|
14857
|
+
ext = sniffedExt;
|
|
14858
|
+
}
|
|
14859
|
+
return truncateBytes(ext ? `${stem}.${ext}` : stem, MAX_NAME_BYTES);
|
|
14860
|
+
}
|
|
14861
|
+
function extensionOf(raw) {
|
|
14862
|
+
const name = safeName(raw).toLowerCase();
|
|
14863
|
+
const dot = name.lastIndexOf(".");
|
|
14864
|
+
if (dot <= 0) return "";
|
|
14865
|
+
const ext = name.slice(dot + 1);
|
|
14866
|
+
return ext.length > 12 || ext.includes("/") ? "" : ext;
|
|
14867
|
+
}
|
|
14868
|
+
function executableName(raw) {
|
|
14869
|
+
const ext = extensionOf(raw);
|
|
14870
|
+
return ext !== "" && DENY_EXTS.includes(ext);
|
|
14871
|
+
}
|
|
14872
|
+
function globToRegExp(pattern) {
|
|
14873
|
+
const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, ".");
|
|
14874
|
+
return new RegExp(`^${escaped}$`);
|
|
14875
|
+
}
|
|
14876
|
+
CREDENTIAL_NAME_PATTERNS.map((pattern) => ({
|
|
14877
|
+
pattern,
|
|
14878
|
+
regex: globToRegExp(pattern)
|
|
14879
|
+
}));
|
|
14880
|
+
const QUIET_MS = 1500;
|
|
14881
|
+
const NOTES_KEPT = 4;
|
|
14882
|
+
const PROGRESS_RENDER_MS = 250;
|
|
14883
|
+
const CAP_SAMPLE_MS = 100;
|
|
14884
|
+
class DownloadArmer {
|
|
14885
|
+
constructor(options) {
|
|
14886
|
+
this.options = options;
|
|
14887
|
+
}
|
|
14888
|
+
captures = /* @__PURE__ */ new Map();
|
|
14889
|
+
notes = [];
|
|
14890
|
+
/** The newest file SAVED into the download directory, and how many have been saved
|
|
14891
|
+
* in this app run, for the durable control's own label (review round 2, U12).
|
|
14892
|
+
*
|
|
14893
|
+
* WHY IT IS NOT DERIVED FROM `notes`: the notes are a bounded NOTIFICATION window
|
|
14894
|
+
* (`NOTES_KEPT` = 4), so a save four decisions ago is gone from them — and the
|
|
14895
|
+
* first draft of this field read the notes, which meant the summary VANISHED
|
|
14896
|
+
* exactly when the user most needed it (a refusal, a refusal, a refusal, and the
|
|
14897
|
+
* trace of the file that did arrive is gone). It is one string and a count, kept
|
|
14898
|
+
* for the life of the process, which is what makes "this session" a true word in
|
|
14899
|
+
* the control's label rather than a bound dressed up as a total. */
|
|
14900
|
+
savedSummary = null;
|
|
14901
|
+
/** The paths this host has PROMISED Chromium, keyed by the DIRECTORY they are
|
|
14902
|
+
* in (review round 2, R2-4).
|
|
14903
|
+
*
|
|
14904
|
+
* WHY IT IS NOT ON THE CAPTURE ANY MORE. A reservation held per capture answers
|
|
14905
|
+
* only the question that capture asked: two same-named downloads on two armed
|
|
14906
|
+
* CALLS into one directory are still decided by the `existsSync` probe alone,
|
|
14907
|
+
* because Chromium creates the file after `setSavePath` returns — so the second
|
|
14908
|
+
* call cannot see the first's path. The directory is what a name can collide in,
|
|
14909
|
+
* so the directory is the key. And a reservation is RELEASED when the write it
|
|
14910
|
+
* was for settles — it either exists on disk (the probe sees it) or was
|
|
14911
|
+
* discarded (the name is free) — rather than consuming its suffix for the rest
|
|
14912
|
+
* of the call, which is how the next download of that name landed as
|
|
14913
|
+
* `name (1).ext` with nothing on disk (review round 2, R2-4). */
|
|
14914
|
+
reserved = /* @__PURE__ */ new Map();
|
|
14915
|
+
/** The owner kind of one tab, or null when this armer cannot say (review round
|
|
14916
|
+
* 2, U10: the kind travels with the decision because the tab may be closed by
|
|
14917
|
+
* the time the row renders it). */
|
|
14918
|
+
ownerKindOf(tabId) {
|
|
14919
|
+
return this.options.ownerKindFor?.(tabId) ?? null;
|
|
14920
|
+
}
|
|
14921
|
+
/** Arm one tab. `dir` is the harness-composed directory (§10.2); it is created
|
|
14922
|
+
* 0700 if missing, because the harness composes it and a race with a session's
|
|
14923
|
+
* own cleanup must not cost the user the file.
|
|
14924
|
+
*
|
|
14925
|
+
* TWO RULES ABOUT THAT DIRECTORY (review round 1, M3), and both exist because
|
|
14926
|
+
* this is the last place between a wire parameter and a `chmod`. ABSOLUTE: a
|
|
14927
|
+
* relative `dir` resolves against the APP's working directory rather than the
|
|
14928
|
+
* session's, which is the shape `upload` already refuses for its own paths. And
|
|
14929
|
+
* the MODE IS ASSERTED ONLY ON A DIRECTORY THIS CALL CREATED: re-moding an
|
|
14930
|
+
* existing path the harness composed — or a symlink to one — would be a chmod on
|
|
14931
|
+
* something this host does not own.
|
|
14932
|
+
*/
|
|
14933
|
+
arm(tabId, dir, timeoutMs) {
|
|
14934
|
+
if (!path$1.isAbsolute(dir)) {
|
|
14935
|
+
throw new BridgeCommandError(
|
|
14936
|
+
"internal",
|
|
14937
|
+
"download needs an absolute directory to write into",
|
|
14938
|
+
{ param: "dir" }
|
|
14939
|
+
);
|
|
14940
|
+
}
|
|
14941
|
+
this.forget(tabId);
|
|
14942
|
+
const created = !fs.existsSync(dir);
|
|
14943
|
+
try {
|
|
14944
|
+
fs.mkdirSync(dir, { recursive: true, mode: 448 });
|
|
14945
|
+
if (created) fs.chmodSync(dir, 448);
|
|
14946
|
+
} catch (error) {
|
|
14947
|
+
throw new BridgeCommandError(
|
|
14948
|
+
"internal",
|
|
14949
|
+
`download needs a directory it can write into: ${describe(error)}`,
|
|
14950
|
+
{ param: "dir" }
|
|
14951
|
+
);
|
|
14952
|
+
}
|
|
14953
|
+
const now = this.options.now ?? Date.now;
|
|
14954
|
+
const capture = {
|
|
14955
|
+
tabId,
|
|
14956
|
+
dir,
|
|
14957
|
+
timeoutMs,
|
|
14958
|
+
startedAt: now(),
|
|
14959
|
+
files: [],
|
|
14960
|
+
refusals: [],
|
|
14961
|
+
pending: 0,
|
|
14962
|
+
started: false,
|
|
14963
|
+
live: [],
|
|
14964
|
+
handedOut: /* @__PURE__ */ new Set(),
|
|
14965
|
+
active: null,
|
|
14966
|
+
lastProgressAt: 0,
|
|
14967
|
+
quietTimer: null,
|
|
14968
|
+
deadlineTimer: null,
|
|
14969
|
+
capTimer: null,
|
|
14970
|
+
finish: null,
|
|
14971
|
+
settled: false
|
|
14972
|
+
};
|
|
14973
|
+
this.captures.set(tabId, capture);
|
|
14974
|
+
const deadline2 = setTimeout(() => this.settle(capture), timeoutMs);
|
|
14975
|
+
deadline2.unref?.();
|
|
14976
|
+
capture.deadlineTimer = deadline2;
|
|
14977
|
+
const capSample = setInterval(
|
|
14978
|
+
() => this.sampleCaps(capture),
|
|
14979
|
+
CAP_SAMPLE_MS
|
|
14980
|
+
);
|
|
14981
|
+
capSample.unref?.();
|
|
14982
|
+
capture.capTimer = capSample;
|
|
14983
|
+
this.options.log(
|
|
14984
|
+
`[browser] armed downloads on tab ${tabId} into ${dir} for ${Math.round(timeoutMs / 1e3)}s`
|
|
14985
|
+
);
|
|
14986
|
+
this.options.onActivity?.();
|
|
14987
|
+
return {
|
|
14988
|
+
done: () => new Promise((resolve) => {
|
|
14989
|
+
const answer = () => {
|
|
14990
|
+
capture.settled = true;
|
|
14991
|
+
resolve(this.resultOf(capture));
|
|
14992
|
+
};
|
|
14993
|
+
if (capture.settled) answer();
|
|
14994
|
+
else capture.finish = answer;
|
|
14995
|
+
})
|
|
14996
|
+
};
|
|
14997
|
+
}
|
|
14998
|
+
/** Drop one tab's arm and stop tracking it. Called from the action's `finally`
|
|
14999
|
+
* and from the single tab-removal path, so a closed tab cannot leave a live
|
|
15000
|
+
* capture behind. */
|
|
15001
|
+
forget(tabId) {
|
|
15002
|
+
const capture = this.captures.get(tabId);
|
|
15003
|
+
if (!capture) return;
|
|
15004
|
+
this.captures.delete(tabId);
|
|
15005
|
+
this.clearTimers(capture);
|
|
15006
|
+
this.cancelLive(
|
|
15007
|
+
capture,
|
|
15008
|
+
"was still being written when the download call ended",
|
|
15009
|
+
{ rule: "interrupted", bytes: 0, limit: 0 }
|
|
15010
|
+
);
|
|
15011
|
+
for (const path2 of capture.handedOut) this.release(capture, path2);
|
|
15012
|
+
if (!capture.settled) {
|
|
15013
|
+
capture.settled = true;
|
|
15014
|
+
capture.finish?.();
|
|
15015
|
+
}
|
|
15016
|
+
}
|
|
15017
|
+
/**
|
|
15018
|
+
* Decide one download attempt. THE ONLY ENTRY POINT `profile.ts` calls.
|
|
15019
|
+
*
|
|
15020
|
+
* The order is deliberate and is the design's: the NAME first (cheapest, and
|
|
15021
|
+
* the only check that can refuse a `.url`/`.reg`/`.command`, whose bytes are
|
|
15022
|
+
* plain text), then the CAP before the write, then the save path.
|
|
15023
|
+
*/
|
|
15024
|
+
decide(item, webContentsId) {
|
|
15025
|
+
const tabId = this.options.tabForWebContents(webContentsId);
|
|
15026
|
+
const capture = tabId === null ? void 0 : this.captures.get(tabId);
|
|
15027
|
+
if (!capture || capture.settled) {
|
|
15028
|
+
return { cancel: true, reason: "no download call is armed on this tab" };
|
|
15029
|
+
}
|
|
15030
|
+
const raw = item.getFilename();
|
|
15031
|
+
const clean = safeName(raw);
|
|
15032
|
+
if (executableName(raw)) {
|
|
15033
|
+
return this.refuse(
|
|
15034
|
+
capture,
|
|
15035
|
+
clean,
|
|
15036
|
+
`refused: \`${clean}\` is an executable/script type; nothing was saved`,
|
|
15037
|
+
{ rule: "executable", bytes: item.getTotalBytes(), limit: 0 }
|
|
15038
|
+
);
|
|
15039
|
+
}
|
|
15040
|
+
if (capture.files.length + capture.pending >= CAPS.downloadMaxFilesPerCall) {
|
|
15041
|
+
return this.refuse(
|
|
15042
|
+
capture,
|
|
15043
|
+
clean,
|
|
15044
|
+
// The row shows this verbatim, so it is written for a person who just
|
|
15045
|
+
// clicked something rather than in tool-call jargon (review round 1, D1):
|
|
15046
|
+
// "call" and "landed" described the MODEL's call, and a user reading the
|
|
15047
|
+
// strip made neither.
|
|
15048
|
+
`refused: this download call has already saved its limit of ${CAPS.downloadMaxFilesPerCall} files; nothing was saved`,
|
|
15049
|
+
{
|
|
15050
|
+
rule: "count",
|
|
15051
|
+
bytes: 0,
|
|
15052
|
+
limit: CAPS.downloadMaxFilesPerCall
|
|
15053
|
+
}
|
|
15054
|
+
);
|
|
15055
|
+
}
|
|
15056
|
+
const total = item.getTotalBytes();
|
|
15057
|
+
if (total > CAPS.downloadMaxBytes) {
|
|
15058
|
+
return this.refuse(
|
|
15059
|
+
capture,
|
|
15060
|
+
clean,
|
|
15061
|
+
`refused: \`${clean}\` is over the ${humanBytes(CAPS.downloadMaxBytes)} per-file download limit; nothing was saved (it is ${total} bytes)`,
|
|
15062
|
+
{ rule: "limit", bytes: total, limit: CAPS.downloadMaxBytes }
|
|
15063
|
+
);
|
|
15064
|
+
}
|
|
15065
|
+
const savePath = this.uniquePath(capture, clean, raw);
|
|
15066
|
+
try {
|
|
15067
|
+
item.setSavePath(savePath);
|
|
15068
|
+
} catch (error) {
|
|
15069
|
+
return this.refuse(
|
|
15070
|
+
capture,
|
|
15071
|
+
clean,
|
|
15072
|
+
`refused: \`${clean}\` could not be saved — the download folder could not be written to (${describe(error)}); nothing was saved`,
|
|
15073
|
+
{ rule: "write", bytes: item.getTotalBytes(), limit: 0 }
|
|
15074
|
+
);
|
|
15075
|
+
}
|
|
15076
|
+
this.track(item, capture, savePath);
|
|
15077
|
+
this.reserve(capture, savePath);
|
|
15078
|
+
return { cancel: false, reason: "" };
|
|
15079
|
+
}
|
|
15080
|
+
/** Promise one path, in this capture's own list and in the directory's index. */
|
|
15081
|
+
reserve(capture, savePath) {
|
|
15082
|
+
capture.handedOut.add(savePath);
|
|
15083
|
+
const held = this.reserved.get(capture.dir);
|
|
15084
|
+
if (held) held.add(savePath);
|
|
15085
|
+
else this.reserved.set(capture.dir, /* @__PURE__ */ new Set([savePath]));
|
|
15086
|
+
}
|
|
15087
|
+
/** Give a reserved name back, because the write it was for has settled (review
|
|
15088
|
+
* round 2, R2-4): the file is on disk, so the probe sees it, or it was
|
|
15089
|
+
* discarded, so the name is free again.
|
|
15090
|
+
*
|
|
15091
|
+
* The capture's OWN list is what decides whether this host ever promised the
|
|
15092
|
+
* path, so a `done` event arriving after the arm was forgotten — the capture
|
|
15093
|
+
* object is still reachable through the listener closure even though the map
|
|
15094
|
+
* entry is gone — cannot release something a different capture reserved. */
|
|
15095
|
+
release(capture, savePath) {
|
|
15096
|
+
if (!capture.handedOut.delete(savePath)) return;
|
|
15097
|
+
const held = this.reserved.get(capture.dir);
|
|
15098
|
+
if (!held) return;
|
|
15099
|
+
held.delete(savePath);
|
|
15100
|
+
if (held.size === 0) this.reserved.delete(capture.dir);
|
|
15101
|
+
}
|
|
15102
|
+
/** What the chrome row renders for ONE tab (§16.4).
|
|
15103
|
+
*
|
|
15104
|
+
* WHY PER TAB (review round 1, D2): `captures` and `notes` are host-wide, and
|
|
15105
|
+
* the row rendered both into EVERY browser tab's chrome — so the strip of the
|
|
15106
|
+
* tab the user is watching could narrate a decision taken in another one, and
|
|
15107
|
+
* did exactly that in the published frames (`04` showed a refusal above a form
|
|
15108
|
+
* holding three attached files). The active tab is the row's subject.
|
|
15109
|
+
*
|
|
15110
|
+
* `null` is "no tab is active", which renders nothing: the strip belongs to a
|
|
15111
|
+
* tab, and there is no tab to speak for.
|
|
15112
|
+
*
|
|
15113
|
+
* WHY THE NOTES ARE HOST-WIDE AND THE TAB IS NAMED INSTEAD OF FILTERED OUT, which
|
|
15114
|
+
* is a correction to this change's own first attempt at D2. Filtering the notes to
|
|
15115
|
+
* the active tab looks like the reviewer's "scope it to the tab whose decision it
|
|
15116
|
+
* was", and it HIDES the case the feature exists for: an agent tab is created
|
|
15117
|
+
* INACTIVE by design (`tabs.ts`: "An agent `open` NEVER changes which tab the user
|
|
15118
|
+
* is looking at", §11.4's focus-safety rule), so an agent's own download would
|
|
15119
|
+
* leave no trace anywhere on screen — the exact complaint U3 files against the
|
|
15120
|
+
* upload path, reintroduced on the download path. What the reviewer's finding
|
|
15121
|
+
* actually needs is that the strip must not APPEAR to be about the page on screen:
|
|
15122
|
+
* so the note keeps its `tabId`, the projection carries `activeTabId`, and the row
|
|
15123
|
+
* says "on the agent's tab" when the two differ.
|
|
15124
|
+
*/
|
|
15125
|
+
activityFor(tabId) {
|
|
15126
|
+
const newest = [...this.captures.values()].at(-1);
|
|
15127
|
+
return {
|
|
15128
|
+
active: newest?.active ?? null,
|
|
15129
|
+
// The DIRECTORY is reported from a live capture even before anything has
|
|
15130
|
+
// started, so the reveal is available for the whole of a call rather than
|
|
15131
|
+
// only after the first file lands. The NAME is not: "Downloading <a
|
|
15132
|
+
// directory>" would be a row that appears before the page has decided to
|
|
15133
|
+
// download anything.
|
|
15134
|
+
dir: this.downloadDir(),
|
|
15135
|
+
notes: [...this.notes].reverse(),
|
|
15136
|
+
activeTabId: tabId,
|
|
15137
|
+
recent: this.savedSummary
|
|
15138
|
+
};
|
|
15139
|
+
}
|
|
15140
|
+
/** The directory the reveal opens (§16.4): the live arm's, else the newest one a
|
|
15141
|
+
* decision used.
|
|
15142
|
+
*
|
|
15143
|
+
* HOST-WIDE ON PURPOSE, which is why it is not `activityFor`'s own field: the
|
|
15144
|
+
* button is about the FOLDER the host writes into, not about a tab. Upload notes
|
|
15145
|
+
* carry no directory, so they cannot answer for one. */
|
|
15146
|
+
downloadDir() {
|
|
15147
|
+
const dirs = this.notes.filter((note) => note.dir !== "").map((n) => n.dir);
|
|
15148
|
+
return [...this.captures.values()].at(-1)?.dir ?? dirs.at(-1) ?? null;
|
|
15149
|
+
}
|
|
15150
|
+
/** One upload's own line (review round 1, U3).
|
|
15151
|
+
*
|
|
15152
|
+
* WHY AN UPLOAD HAS A NOTE AT ALL, when §16.4 deliberately has no upload
|
|
15153
|
+
* affordance: the surface asymmetry was defensible while nothing was going
|
|
15154
|
+
* wrong, but an upload is the more dangerous verb — and the QA matrix showed
|
|
15155
|
+
* three files leaving the machine with the strip silent, or (worse) still
|
|
15156
|
+
* narrating an unrelated download refusal while they left. One line naming what
|
|
15157
|
+
* went where is not the per-file list §16.4 rules out; it is the same rule the
|
|
15158
|
+
* download half follows, applied to the half that had none.
|
|
15159
|
+
*
|
|
15160
|
+
* The SITE comes from the page the action actually reported (never from a URL a
|
|
15161
|
+
* caller composed), and the names are the sanitised ones the host attached. */
|
|
15162
|
+
noteUpload(tabId, files, site) {
|
|
15163
|
+
if (files.length === 0) return;
|
|
15164
|
+
this.notes.push({
|
|
15165
|
+
name: files[0]?.name ?? "",
|
|
15166
|
+
count: files.length,
|
|
15167
|
+
dir: "",
|
|
15168
|
+
outcome: "sent",
|
|
15169
|
+
reason: "",
|
|
15170
|
+
at: (this.options.now ?? Date.now)(),
|
|
15171
|
+
direction: "upload",
|
|
15172
|
+
tabId,
|
|
15173
|
+
site,
|
|
15174
|
+
refusal: null,
|
|
15175
|
+
ownerKind: this.ownerKindOf(tabId)
|
|
15176
|
+
});
|
|
15177
|
+
while (this.notes.length > NOTES_KEPT) this.notes.shift();
|
|
15178
|
+
this.options.onActivity?.();
|
|
15179
|
+
}
|
|
15180
|
+
// ---- the item lifecycle --------------------------------------------------
|
|
15181
|
+
track(item, capture, savePath) {
|
|
15182
|
+
capture.started = true;
|
|
15183
|
+
capture.pending += 1;
|
|
15184
|
+
const entry = { item, savePath, weCancelled: false };
|
|
15185
|
+
capture.live.push(entry);
|
|
15186
|
+
this.showProgress(capture, entry);
|
|
15187
|
+
if (capture.quietTimer) {
|
|
15188
|
+
clearTimeout(capture.quietTimer);
|
|
15189
|
+
capture.quietTimer = null;
|
|
15190
|
+
}
|
|
15191
|
+
this.options.onActivity?.();
|
|
15192
|
+
item.on("updated", () => {
|
|
15193
|
+
if (entry.weCancelled) return;
|
|
15194
|
+
if (capture.settled) {
|
|
15195
|
+
this.refuseLive(
|
|
15196
|
+
capture,
|
|
15197
|
+
entry,
|
|
15198
|
+
`\`${path$1.basename(savePath)}\` was still being written after this call's answer`,
|
|
15199
|
+
{
|
|
15200
|
+
rule: "interrupted",
|
|
15201
|
+
bytes: item.getReceivedBytes(),
|
|
15202
|
+
limit: 0
|
|
15203
|
+
}
|
|
15204
|
+
);
|
|
15205
|
+
return;
|
|
15206
|
+
}
|
|
15207
|
+
if (item.getReceivedBytes() > CAPS.downloadMaxBytes) {
|
|
15208
|
+
this.overrun(capture, entry);
|
|
15209
|
+
return;
|
|
15210
|
+
}
|
|
15211
|
+
this.showProgress(capture, entry);
|
|
15212
|
+
});
|
|
15213
|
+
item.once("done", (_event, state) => {
|
|
15214
|
+
capture.pending -= 1;
|
|
15215
|
+
capture.live = capture.live.filter((live) => live !== entry);
|
|
15216
|
+
const name = path$1.basename(savePath);
|
|
15217
|
+
this.release(capture, savePath);
|
|
15218
|
+
if (entry.weCancelled) {
|
|
15219
|
+
if (state !== "completed") discardPartial(savePath, this.options.log);
|
|
15220
|
+
} else if (state === "completed") {
|
|
15221
|
+
restrictMode(savePath, this.options.log);
|
|
15222
|
+
capture.files.push(fileFact(item, savePath));
|
|
15223
|
+
this.note(capture, name, "saved", 1, "", null);
|
|
15224
|
+
} else {
|
|
15225
|
+
const reason = capture.settled ? `refused: \`${name}\` did not finish (${state}) after this call's answer; the partial file was discarded` : `refused: \`${name}\` did not finish (${state}); the partial file was discarded`;
|
|
15226
|
+
discardPartial(savePath, this.options.log);
|
|
15227
|
+
if (!capture.settled) capture.refusals.push(reason);
|
|
15228
|
+
this.note(capture, name, "refused", 1, reason, {
|
|
15229
|
+
rule: "interrupted",
|
|
15230
|
+
bytes: item.getReceivedBytes(),
|
|
15231
|
+
limit: 0
|
|
15232
|
+
});
|
|
15233
|
+
}
|
|
15234
|
+
this.showProgress(capture);
|
|
15235
|
+
this.options.onActivity?.();
|
|
15236
|
+
if (!capture.settled && capture.pending === 0) this.armQuiet(capture);
|
|
15237
|
+
});
|
|
15238
|
+
}
|
|
15239
|
+
/** What the row says is in flight, refreshed on the render throttle.
|
|
15240
|
+
*
|
|
15241
|
+
* The LAST tracked item is the one the row names, which is the item a page that
|
|
15242
|
+
* starts several files is currently growing; the byte counts come from the item
|
|
15243
|
+
* itself, never from a sum this module keeps (a sum would be a second account of
|
|
15244
|
+
* the same write). The throttle is what keeps a 40 MB download from sending a
|
|
15245
|
+
* message per chunk to the renderer. */
|
|
15246
|
+
showProgress(capture, entry) {
|
|
15247
|
+
const last = entry ?? capture.live.at(-1);
|
|
15248
|
+
capture.active = last ? {
|
|
15249
|
+
name: path$1.basename(last.savePath),
|
|
15250
|
+
received: last.item.getReceivedBytes(),
|
|
15251
|
+
total: last.item.getTotalBytes(),
|
|
15252
|
+
tabId: capture.tabId
|
|
15253
|
+
} : null;
|
|
15254
|
+
const now = (this.options.now ?? Date.now)();
|
|
15255
|
+
if (now - capture.lastProgressAt < PROGRESS_RENDER_MS) return;
|
|
15256
|
+
capture.lastProgressAt = now;
|
|
15257
|
+
this.options.onActivity?.();
|
|
15258
|
+
}
|
|
15259
|
+
/** THE RUNTIME CAP'S REFUSAL, IN ONE PLACE (review round 2, Q4). Two triggers fire
|
|
15260
|
+
* it now — Chromium's `updated` and this host's own sampler — and two spellings
|
|
15261
|
+
* of one rule is exactly how the row's copy and the tool result stop agreeing.
|
|
15262
|
+
*
|
|
15263
|
+
* The RULE is `overrun` rather than `limit` (review round 2, R2-5): this case
|
|
15264
|
+
* cancelled a write that was already on disk, and the row must not tell the user
|
|
15265
|
+
* "Nothing was saved." about a partial it discarded. The SENTENCE is unchanged,
|
|
15266
|
+
* because it is the tool result's and QA and the review both read it as right. */
|
|
15267
|
+
overrun(capture, entry) {
|
|
15268
|
+
this.refuseLive(
|
|
15269
|
+
capture,
|
|
15270
|
+
entry,
|
|
15271
|
+
`\`${path$1.basename(entry.savePath)}\` went over the ${humanBytes(CAPS.downloadMaxBytes)} per-file download limit while it was being written`,
|
|
15272
|
+
{
|
|
15273
|
+
rule: "overrun",
|
|
15274
|
+
bytes: entry.item.getReceivedBytes(),
|
|
15275
|
+
limit: CAPS.downloadMaxBytes
|
|
15276
|
+
}
|
|
15277
|
+
);
|
|
15278
|
+
}
|
|
15279
|
+
/** Sample every still-writing transfer's own byte count for the runtime cap.
|
|
15280
|
+
*
|
|
15281
|
+
* WHY THIS EXISTS BESIDE THE `updated` CHECK (review round 2, Q4): `updated` is
|
|
15282
|
+
* Chromium's event, and QA measured it silent for the whole of a 700 MiB write,
|
|
15283
|
+
* so on its own it bounds the disk by the cap plus however long the origin stays
|
|
15284
|
+
* quiet. This reads `getReceivedBytes()` — the item's own counter, the same one
|
|
15285
|
+
* the refusal reports — so the ceiling is the host's to enforce rather than the
|
|
15286
|
+
* page's throughput to decide. It reports nothing of its own: the refusal, the
|
|
15287
|
+
* note and the cancel all go through `overrun`, so a sample can never produce a
|
|
15288
|
+
* second account of a write the event already cancelled. */
|
|
15289
|
+
sampleCaps(capture) {
|
|
15290
|
+
if (capture.settled) return;
|
|
15291
|
+
for (const entry of [...capture.live]) {
|
|
15292
|
+
if (entry.weCancelled) continue;
|
|
15293
|
+
if (entry.item.getReceivedBytes() <= CAPS.downloadMaxBytes) continue;
|
|
15294
|
+
this.overrun(capture, entry);
|
|
15295
|
+
}
|
|
15296
|
+
}
|
|
15297
|
+
/** Report a download this host is cancelling, and cancel it.
|
|
15298
|
+
*
|
|
15299
|
+
* ONE PATH FOR ALL THREE KINDS OF CANCEL (the runtime cap, the deadline, the
|
|
15300
|
+
* call going away), because they differ only in the clause that explains them
|
|
15301
|
+
* and a second copy would be a second chance to forget the note, the refusal or
|
|
15302
|
+
* the cancel itself.
|
|
15303
|
+
*
|
|
15304
|
+
* `weCancelled` is set HERE rather than by the callers, and it is what makes the
|
|
15305
|
+
* sentence survivable: the `done` event our own `cancel()` produces arrives after
|
|
15306
|
+
* the answer in the deadline case, and the handler must then discard the residue
|
|
15307
|
+
* without writing a second note. */
|
|
15308
|
+
refuseLive(capture, entry, clause, refusal) {
|
|
15309
|
+
entry.weCancelled = true;
|
|
15310
|
+
const reason = `refused: ${clause}; it was cancelled and the partial file was discarded`;
|
|
15311
|
+
capture.refusals.push(reason);
|
|
15312
|
+
this.note(capture, path$1.basename(entry.savePath), "refused", 1, reason, refusal);
|
|
15313
|
+
this.options.log(`[browser] ${reason}`);
|
|
15314
|
+
if (entry.item.getState() !== "progressing") return;
|
|
15315
|
+
try {
|
|
15316
|
+
entry.item.cancel();
|
|
15317
|
+
} catch (error) {
|
|
15318
|
+
this.options.log(
|
|
15319
|
+
`[browser] could not cancel ${path$1.basename(entry.savePath)}: ${describe(error)}`
|
|
15320
|
+
);
|
|
15321
|
+
}
|
|
15322
|
+
if (entry.item.getState() !== "completed") {
|
|
15323
|
+
discardPartial(entry.savePath, this.options.log);
|
|
15324
|
+
this.release(capture, entry.savePath);
|
|
15325
|
+
}
|
|
15326
|
+
this.options.onActivity?.();
|
|
15327
|
+
}
|
|
15328
|
+
/** Cancel every download this capture still has writing. */
|
|
15329
|
+
cancelLive(capture, clause, refusal) {
|
|
15330
|
+
const live = [...capture.live];
|
|
15331
|
+
capture.live = [];
|
|
15332
|
+
for (const entry of live) {
|
|
15333
|
+
if (entry.weCancelled) continue;
|
|
15334
|
+
this.refuseLive(
|
|
15335
|
+
capture,
|
|
15336
|
+
entry,
|
|
15337
|
+
`\`${path$1.basename(entry.savePath)}\` ${clause}`,
|
|
15338
|
+
{ ...refusal, bytes: entry.item.getReceivedBytes() }
|
|
15339
|
+
);
|
|
15340
|
+
}
|
|
15341
|
+
}
|
|
15342
|
+
armQuiet(capture) {
|
|
15343
|
+
if (capture.quietTimer) clearTimeout(capture.quietTimer);
|
|
15344
|
+
const timer = setTimeout(
|
|
15345
|
+
() => this.settle(capture),
|
|
15346
|
+
this.options.quietMs ?? QUIET_MS
|
|
15347
|
+
);
|
|
15348
|
+
timer.unref?.();
|
|
15349
|
+
capture.quietTimer = timer;
|
|
15350
|
+
}
|
|
15351
|
+
/**
|
|
15352
|
+
* Answer the call.
|
|
15353
|
+
*
|
|
15354
|
+
* THE TWO ARMS ARE THE DEADLINE AND THE QUIET WINDOW, and the quiet window never
|
|
15355
|
+
* fires with a write in flight (it is only armed at `pending === 0`), so it always
|
|
15356
|
+
* has the finished files Python is about to inspect. A DEADLINE can arrive
|
|
15357
|
+
* mid-write, and it answers anyway — a call that never returns is worse than one
|
|
15358
|
+
* that admits what it could not finish — after cancelling what was still writing,
|
|
15359
|
+
* because that is the one file the harness could not describe honestly.
|
|
15360
|
+
*/
|
|
15361
|
+
settle(capture) {
|
|
15362
|
+
if (capture.settled) return;
|
|
15363
|
+
if (capture.pending > 0) {
|
|
15364
|
+
this.cancelLive(
|
|
15365
|
+
capture,
|
|
15366
|
+
`was still being written when the ${Math.round(capture.timeoutMs / 1e3)}s budget expired`,
|
|
15367
|
+
{
|
|
15368
|
+
rule: "deadline",
|
|
15369
|
+
bytes: 0,
|
|
15370
|
+
limit: Math.round(capture.timeoutMs / 1e3)
|
|
15371
|
+
}
|
|
15372
|
+
);
|
|
15373
|
+
}
|
|
15374
|
+
capture.settled = true;
|
|
15375
|
+
capture.active = null;
|
|
15376
|
+
this.clearTimers(capture);
|
|
15377
|
+
capture.finish?.();
|
|
15378
|
+
this.options.onActivity?.();
|
|
15379
|
+
}
|
|
15380
|
+
resultOf(capture) {
|
|
15381
|
+
const reasons = [...capture.refusals];
|
|
15382
|
+
if (capture.files.length === 0 && reasons.length === 0) {
|
|
15383
|
+
const waited = Math.max(
|
|
15384
|
+
1,
|
|
15385
|
+
Math.round(
|
|
15386
|
+
((this.options.now ?? Date.now)() - capture.startedAt) / 1e3
|
|
15387
|
+
)
|
|
15388
|
+
);
|
|
15389
|
+
reasons.push(
|
|
15390
|
+
`no download started within ${waited}s; if the page needs a click first, pass a selector, or \`click\` it and retry`
|
|
15391
|
+
);
|
|
15392
|
+
}
|
|
15393
|
+
return {
|
|
15394
|
+
// `armed: false` is a POLICY answer, not a fault (§6.1). The app host always
|
|
15395
|
+
// arms when the harness asks it to and reports true; the harness's own
|
|
15396
|
+
// pre-arm refusals (its per-call checks before dispatching) are the caller of
|
|
15397
|
+
// the other arm.
|
|
15398
|
+
armed: true,
|
|
15399
|
+
files: capture.files,
|
|
15400
|
+
reason: reasons.join("; ")
|
|
15401
|
+
};
|
|
15402
|
+
}
|
|
15403
|
+
refuse(capture, clean, reason, refusal) {
|
|
15404
|
+
capture.refusals.push(reason);
|
|
15405
|
+
this.note(capture, clean, "refused", 1, reason, refusal);
|
|
15406
|
+
this.options.log(`[browser] ${reason}`);
|
|
15407
|
+
return { cancel: true, reason };
|
|
15408
|
+
}
|
|
15409
|
+
note(capture, name, outcome, count, reason, refusal) {
|
|
15410
|
+
this.notes.push({
|
|
15411
|
+
name,
|
|
15412
|
+
count,
|
|
15413
|
+
dir: capture.dir,
|
|
15414
|
+
outcome,
|
|
15415
|
+
reason,
|
|
15416
|
+
at: (this.options.now ?? Date.now)(),
|
|
15417
|
+
direction: "download",
|
|
15418
|
+
tabId: capture.tabId,
|
|
15419
|
+
site: "",
|
|
15420
|
+
refusal,
|
|
15421
|
+
ownerKind: this.ownerKindOf(capture.tabId)
|
|
15422
|
+
});
|
|
15423
|
+
if (outcome === "saved") {
|
|
15424
|
+
this.savedSummary = {
|
|
15425
|
+
name,
|
|
15426
|
+
count: (this.savedSummary?.count ?? 0) + 1
|
|
15427
|
+
};
|
|
15428
|
+
}
|
|
15429
|
+
while (this.notes.length > NOTES_KEPT) this.notes.shift();
|
|
15430
|
+
}
|
|
15431
|
+
clearTimers(capture) {
|
|
15432
|
+
if (capture.quietTimer) clearTimeout(capture.quietTimer);
|
|
15433
|
+
if (capture.deadlineTimer) clearTimeout(capture.deadlineTimer);
|
|
15434
|
+
if (capture.capTimer) clearInterval(capture.capTimer);
|
|
15435
|
+
capture.quietTimer = null;
|
|
15436
|
+
capture.deadlineTimer = null;
|
|
15437
|
+
capture.capTimer = null;
|
|
15438
|
+
}
|
|
15439
|
+
/**
|
|
15440
|
+
* A path in `dir` that neither exists nor has already been PROMISED in this
|
|
15441
|
+
* capture. The page's name is never obeyed beyond its sanitised basename, and
|
|
15442
|
+
* §11.4's rule is "no silent overwrite": the app host uniquifies with
|
|
15443
|
+
* `name (1).ext` rather than letting a second download of the same name replace
|
|
15444
|
+
* the first.
|
|
15445
|
+
*
|
|
15446
|
+
* THE RESERVATION IS THE FIX, and the earlier comment here was simply wrong
|
|
15447
|
+
* (review round 1, Q1/M4). It claimed two downloads of one name on one tab
|
|
15448
|
+
* "cannot race (an arm accepts them one at a time)" — being DECIDED one at a
|
|
15449
|
+
* time is not being CREATED one at a time: Chromium creates the file after
|
|
15450
|
+
* `setSavePath` returns, so two accepted downloads of one name are both in
|
|
15451
|
+
* flight with the disk still empty, both probe the same path, and the second
|
|
15452
|
+
* silently overwrites the first while the result reports two files at one path.
|
|
15453
|
+
* The probe therefore runs against the filesystem AND against the paths this
|
|
15454
|
+
* capture has already handed out.
|
|
15455
|
+
*
|
|
15456
|
+
* It is still not an atomic create, and that is honest rather than sloppy: the
|
|
15457
|
+
* value is handed to Chromium, which opens the file itself, so a
|
|
15458
|
+
* create-and-release probe would only add a window where the file exists empty.
|
|
15459
|
+
* The reservation closes the window the probe could not see.
|
|
15460
|
+
*/
|
|
15461
|
+
uniquePath(capture, clean, raw) {
|
|
15462
|
+
const reserved = this.reserved.get(capture.dir);
|
|
15463
|
+
const taken = (path2) => fs.existsSync(path2) || (reserved?.has(path2) ?? false);
|
|
15464
|
+
const first = path$1.join(capture.dir, clean);
|
|
15465
|
+
if (!taken(first)) return first;
|
|
15466
|
+
const dot = clean.lastIndexOf(".");
|
|
15467
|
+
const stem = dot > 0 ? clean.slice(0, dot) : clean;
|
|
15468
|
+
const ext = dot > 0 ? clean.slice(dot + 1) : "";
|
|
15469
|
+
for (let index = 1; index <= 999; index += 1) {
|
|
15470
|
+
const candidate = path$1.join(
|
|
15471
|
+
capture.dir,
|
|
15472
|
+
ext ? `${stem} (${index}).${ext}` : `${stem} (${index})`
|
|
15473
|
+
);
|
|
15474
|
+
if (!taken(candidate)) return candidate;
|
|
15475
|
+
}
|
|
15476
|
+
return path$1.join(capture.dir, safeName(raw, extensionOf(raw) || "bin"));
|
|
15477
|
+
}
|
|
15478
|
+
}
|
|
15479
|
+
function fileFact(item, savePath) {
|
|
15480
|
+
return {
|
|
15481
|
+
name: path$1.basename(savePath),
|
|
15482
|
+
path: savePath,
|
|
15483
|
+
bytes: item.getReceivedBytes(),
|
|
15484
|
+
mime: item.getMimeType() || "",
|
|
15485
|
+
sniffed: "",
|
|
15486
|
+
sha256: ""
|
|
15487
|
+
};
|
|
15488
|
+
}
|
|
15489
|
+
function describe(error) {
|
|
15490
|
+
return error instanceof Error ? error.message : String(error);
|
|
15491
|
+
}
|
|
15492
|
+
function humanBytes(bytes) {
|
|
15493
|
+
const MiB = 1024 * 1024;
|
|
15494
|
+
return `${Math.floor(bytes / MiB)} MiB`;
|
|
15495
|
+
}
|
|
15496
|
+
function discardPartial(path2, log) {
|
|
15497
|
+
try {
|
|
15498
|
+
if (fs.existsSync(path2)) {
|
|
15499
|
+
fs.unlinkSync(path2);
|
|
15500
|
+
log(`[browser] discarded the partial download at ${path$1.basename(path2)}`);
|
|
15501
|
+
}
|
|
15502
|
+
} catch (error) {
|
|
15503
|
+
log(
|
|
15504
|
+
`[browser] could not discard the partial download at ${path$1.basename(path2)}: ${describe(error)}`
|
|
15505
|
+
);
|
|
15506
|
+
}
|
|
15507
|
+
}
|
|
15508
|
+
function restrictMode(path2, log) {
|
|
15509
|
+
try {
|
|
15510
|
+
fs.chmodSync(path2, 384);
|
|
15511
|
+
} catch (error) {
|
|
15512
|
+
log(
|
|
15513
|
+
`[browser] could not restrict ${path$1.basename(path2)} to 0600: ${describe(error)}`
|
|
15514
|
+
);
|
|
15515
|
+
}
|
|
15516
|
+
}
|
|
14626
15517
|
function requesterOf(params, requestId2) {
|
|
14627
15518
|
const supplied = typeof params.requester === "string" ? params.requester.trim() : "";
|
|
14628
15519
|
return supplied.startsWith("session:") ? supplied : requestId2;
|
|
@@ -15275,10 +16166,10 @@ async function type(ctx, params) {
|
|
|
15275
16166
|
returnByValue: true
|
|
15276
16167
|
});
|
|
15277
16168
|
await ctx.cdp.send(contents, "Input.insertText", { text });
|
|
15278
|
-
const
|
|
15279
|
-
if (
|
|
16169
|
+
const readBack2 = await conversationReadBack(ctx, node.objectId, contents);
|
|
16170
|
+
if (readBack2.includes(text)) {
|
|
15280
16171
|
ctx.registry.touch(record);
|
|
15281
|
-
return { value:
|
|
16172
|
+
return { value: readBack2, via: "insert_text", ...pageOf(record.view) };
|
|
15282
16173
|
}
|
|
15283
16174
|
const set = await ctx.cdp.send(
|
|
15284
16175
|
contents,
|
|
@@ -15292,9 +16183,9 @@ async function type(ctx, params) {
|
|
|
15292
16183
|
);
|
|
15293
16184
|
ctx.registry.touch(record);
|
|
15294
16185
|
return {
|
|
15295
|
-
value: String(set?.result?.value ??
|
|
16186
|
+
value: String(set?.result?.value ?? readBack2),
|
|
15296
16187
|
via: "value_setter",
|
|
15297
|
-
insert_text_readback:
|
|
16188
|
+
insert_text_readback: readBack2,
|
|
15298
16189
|
...pageOf(record.view)
|
|
15299
16190
|
};
|
|
15300
16191
|
}
|
|
@@ -15310,6 +16201,41 @@ async function conversationReadBack(ctx, objectId, contents) {
|
|
|
15310
16201
|
);
|
|
15311
16202
|
return String(out?.result?.value ?? "");
|
|
15312
16203
|
}
|
|
16204
|
+
async function download(ctx, params) {
|
|
16205
|
+
const record = ctx.registry.requireSurface(params.tab);
|
|
16206
|
+
const dir = stringParam(params, "dir");
|
|
16207
|
+
if (!dir) {
|
|
16208
|
+
throw new BridgeCommandError(
|
|
16209
|
+
"internal",
|
|
16210
|
+
"download needs the harness-composed directory to write into",
|
|
16211
|
+
{ param: "dir" }
|
|
16212
|
+
);
|
|
16213
|
+
}
|
|
16214
|
+
const timeoutS = clampTimeout(numberParam(params, "timeout_s"));
|
|
16215
|
+
const selector = stringParam(params, "selector");
|
|
16216
|
+
try {
|
|
16217
|
+
const arm = ctx.downloads.arm(record.tabId, dir, timeoutS * 1e3);
|
|
16218
|
+
if (selector) await click(ctx, params);
|
|
16219
|
+
const result = await arm.done();
|
|
16220
|
+
ctx.registry.touch(record);
|
|
16221
|
+
return {
|
|
16222
|
+
files: result.files,
|
|
16223
|
+
armed: result.armed,
|
|
16224
|
+
reason: result.reason,
|
|
16225
|
+
// The page's own identity, as every other action reports it: a caller that
|
|
16226
|
+
// named a selector wants to know what it was looking at when the file
|
|
16227
|
+
// started, and a click can have moved the page.
|
|
16228
|
+
...pageOf(record.view)
|
|
16229
|
+
};
|
|
16230
|
+
} finally {
|
|
16231
|
+
ctx.downloads.forget(record.tabId);
|
|
16232
|
+
}
|
|
16233
|
+
}
|
|
16234
|
+
function clampTimeout(requested) {
|
|
16235
|
+
const wanted = requested === void 0 ? CAPS.downloadTimeoutS : requested;
|
|
16236
|
+
if (!Number.isFinite(wanted) || wanted <= 0) return CAPS.downloadTimeoutS;
|
|
16237
|
+
return Math.min(wanted, CAPS.downloadTimeoutMaxS);
|
|
16238
|
+
}
|
|
15313
16239
|
const MAX_AGENT_TABS = 8;
|
|
15314
16240
|
const BACKGROUND_VIEWPORT = {
|
|
15315
16241
|
x: 0,
|
|
@@ -15432,6 +16358,24 @@ class TabRegistry {
|
|
|
15432
16358
|
(record) => !record.view.webContents.isDestroyed() && record.view.webContents.id === webContentsId
|
|
15433
16359
|
);
|
|
15434
16360
|
}
|
|
16361
|
+
/** The tab a webContents belongs to, or null.
|
|
16362
|
+
*
|
|
16363
|
+
* WHY THIS EXISTS: `will-download` is a SESSION-level handler (Electron's own
|
|
16364
|
+
* split, see `profile.ts`), and the only thing it hands the handler that names a
|
|
16365
|
+
* tab is the WebContents that started the download. Without this lookup the
|
|
16366
|
+
* host could not tell an armed tab from an unarmed one, and every download would
|
|
16367
|
+
* have to be refused — which is what it did before the file-transfer feature.
|
|
16368
|
+
*
|
|
16369
|
+
* A linear scan is deliberate: the set is bounded by the agent-tab cap plus the
|
|
16370
|
+
* user's own tabs, it runs once per download, and a second index keyed by
|
|
16371
|
+
* webContents id is one more thing that can go stale when a view dies. */
|
|
16372
|
+
byWebContents(webContentsId) {
|
|
16373
|
+
if (!Number.isSafeInteger(webContentsId)) return null;
|
|
16374
|
+
for (const record of this.tabs.values()) {
|
|
16375
|
+
if (record.view.webContents.id === webContentsId) return record;
|
|
16376
|
+
}
|
|
16377
|
+
return null;
|
|
16378
|
+
}
|
|
15435
16379
|
get activeTab() {
|
|
15436
16380
|
return this.activeTabId === null ? null : this.tabs.get(this.activeTabId) ?? null;
|
|
15437
16381
|
}
|
|
@@ -15964,6 +16908,202 @@ function retitle() {
|
|
|
15964
16908
|
reason: "the browser tab is labelled with the page title"
|
|
15965
16909
|
};
|
|
15966
16910
|
}
|
|
16911
|
+
const READ_FILES_FUNCTION = `function () {
|
|
16912
|
+
if (!("files" in this)) return null;
|
|
16913
|
+
const descriptor = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "files");
|
|
16914
|
+
let files;
|
|
16915
|
+
try { files = descriptor && descriptor.get ? descriptor.get.call(this) : this.files; }
|
|
16916
|
+
catch (err) { return null; }
|
|
16917
|
+
if (!files) return null;
|
|
16918
|
+
const list = Array.from(files);
|
|
16919
|
+
return { count: list.length, names: list.map((f) => f.name), sizes: list.map((f) => f.size) };
|
|
16920
|
+
}`;
|
|
16921
|
+
const NOT_A_FILE_INPUT = 'that selector is not a file input; snapshot the page and use the element that <input type="file"> names';
|
|
16922
|
+
const CONTEXT_GONE = [
|
|
16923
|
+
"Cannot find context with specified id",
|
|
16924
|
+
"Cannot find execution context",
|
|
16925
|
+
"Execution context was destroyed",
|
|
16926
|
+
"Inspected target navigated or closed"
|
|
16927
|
+
];
|
|
16928
|
+
const NODE_GONE = [
|
|
16929
|
+
"Node with given id does not belong to the document",
|
|
16930
|
+
"No node with given id found",
|
|
16931
|
+
"Could not find node with given id"
|
|
16932
|
+
];
|
|
16933
|
+
function isContextGone(error) {
|
|
16934
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
16935
|
+
return CONTEXT_GONE.some((marker) => message.includes(marker));
|
|
16936
|
+
}
|
|
16937
|
+
function isNodeGone(error) {
|
|
16938
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
16939
|
+
return NODE_GONE.some((marker) => message.includes(marker));
|
|
16940
|
+
}
|
|
16941
|
+
function describeError(error) {
|
|
16942
|
+
const message = (error instanceof Error ? error.message : String(error)).replace(/\s+/g, " ").trim();
|
|
16943
|
+
return message.slice(0, 120) || "no detail";
|
|
16944
|
+
}
|
|
16945
|
+
async function upload(ctx, params) {
|
|
16946
|
+
const record = ctx.registry.requireSurface(params.tab);
|
|
16947
|
+
const selector = stringParam(params, "selector");
|
|
16948
|
+
if (!selector) {
|
|
16949
|
+
throw new BridgeCommandError(
|
|
16950
|
+
"element_not_found",
|
|
16951
|
+
"upload needs a selector naming the file input"
|
|
16952
|
+
);
|
|
16953
|
+
}
|
|
16954
|
+
const paths = uploadPaths(params);
|
|
16955
|
+
const contents = record.view.webContents;
|
|
16956
|
+
const node = await resolveNode(ctx, record, selector);
|
|
16957
|
+
const accept = await acceptsOf(ctx, record, node.nodeId);
|
|
16958
|
+
await ctx.cdp.send(contents, "DOM.setFileInputFiles", {
|
|
16959
|
+
files: paths,
|
|
16960
|
+
nodeId: node.nodeId
|
|
16961
|
+
}).catch((error) => {
|
|
16962
|
+
if (!/not a file input/i.test(String(error))) throw error;
|
|
16963
|
+
throw new BridgeCommandError("element_not_found", NOT_A_FILE_INPUT, {
|
|
16964
|
+
selector,
|
|
16965
|
+
accept
|
|
16966
|
+
});
|
|
16967
|
+
});
|
|
16968
|
+
let held = null;
|
|
16969
|
+
let readback = "";
|
|
16970
|
+
try {
|
|
16971
|
+
held = await readBack(ctx, record, node.objectId);
|
|
16972
|
+
} catch (error) {
|
|
16973
|
+
readback = isContextGone(error) ? "unavailable — the page replaced its document from the change event before the input could be read back" : isNodeGone(error) ? "unavailable — the input was replaced or removed before it could be read back" : `unavailable — the read-back failed (${describeError(error)})`;
|
|
16974
|
+
}
|
|
16975
|
+
if (!readback) {
|
|
16976
|
+
if (!held) {
|
|
16977
|
+
throw new BridgeCommandError("element_not_found", NOT_A_FILE_INPUT, {
|
|
16978
|
+
selector,
|
|
16979
|
+
accept
|
|
16980
|
+
});
|
|
16981
|
+
}
|
|
16982
|
+
assertHolds(paths, held, selector, accept);
|
|
16983
|
+
}
|
|
16984
|
+
ctx.registry.touch(record);
|
|
16985
|
+
const facts = paths.map(factOf);
|
|
16986
|
+
const page = pageOf(record.view);
|
|
16987
|
+
ctx.downloads.noteUpload(record.tabId, facts, siteOf(page.url));
|
|
16988
|
+
return {
|
|
16989
|
+
// The selectors that accepted files. One entry today, because the wire takes
|
|
16990
|
+
// one `selector`; the field is a list because the result vocabulary is shared
|
|
16991
|
+
// with the extension host, whose `DOM.setFileInputFiles` call can address the
|
|
16992
|
+
// same shape.
|
|
16993
|
+
inputs: [selector],
|
|
16994
|
+
accepted: facts,
|
|
16995
|
+
// The host's own word about its read: "" when it completed, a sentence when it
|
|
16996
|
+
// could not. The harness reports the attach as UNVERIFIED when this is set
|
|
16997
|
+
// (`tools/builtin.py`: `verified = count >= 0 and not readback_reported`) rather
|
|
16998
|
+
// than turning a completed egress into an error.
|
|
16999
|
+
readback,
|
|
17000
|
+
// The page's own identity, as every other action reports it: the harness's audit
|
|
17001
|
+
// row for this call records the origin the files went TO, and a host that answered
|
|
17002
|
+
// without it would leave that row naming nowhere.
|
|
17003
|
+
...page
|
|
17004
|
+
};
|
|
17005
|
+
}
|
|
17006
|
+
function uploadPaths(params) {
|
|
17007
|
+
const raw = params.paths;
|
|
17008
|
+
if (!Array.isArray(raw) || raw.length === 0) {
|
|
17009
|
+
throw new BridgeCommandError(
|
|
17010
|
+
"internal",
|
|
17011
|
+
"upload needs a non-empty list of paths",
|
|
17012
|
+
{ param: "paths" }
|
|
17013
|
+
);
|
|
17014
|
+
}
|
|
17015
|
+
const paths = [];
|
|
17016
|
+
for (const entry of raw) {
|
|
17017
|
+
if (typeof entry !== "string" || !entry.trim()) {
|
|
17018
|
+
throw new BridgeCommandError(
|
|
17019
|
+
"internal",
|
|
17020
|
+
"every upload path must be a non-empty string",
|
|
17021
|
+
{ param: "paths" }
|
|
17022
|
+
);
|
|
17023
|
+
}
|
|
17024
|
+
const path2 = entry.trim();
|
|
17025
|
+
if (!path$1.isAbsolute(path2)) {
|
|
17026
|
+
throw new BridgeCommandError(
|
|
17027
|
+
"internal",
|
|
17028
|
+
`upload needs absolute paths; ${safeName(path2)} is not one`,
|
|
17029
|
+
{ param: "paths" }
|
|
17030
|
+
);
|
|
17031
|
+
}
|
|
17032
|
+
paths.push(path2);
|
|
17033
|
+
}
|
|
17034
|
+
if (paths.length > CAPS.uploadMaxFiles) {
|
|
17035
|
+
throw new BridgeCommandError(
|
|
17036
|
+
"internal",
|
|
17037
|
+
`upload takes at most ${CAPS.uploadMaxFiles} files per call`,
|
|
17038
|
+
{ param: "paths", limit: CAPS.uploadMaxFiles }
|
|
17039
|
+
);
|
|
17040
|
+
}
|
|
17041
|
+
return paths;
|
|
17042
|
+
}
|
|
17043
|
+
async function acceptsOf(ctx, record, nodeId) {
|
|
17044
|
+
const attributes = await ctx.cdp.send(
|
|
17045
|
+
record.view.webContents,
|
|
17046
|
+
"DOM.getAttributes",
|
|
17047
|
+
{ nodeId }
|
|
17048
|
+
);
|
|
17049
|
+
const list = attributes?.attributes ?? [];
|
|
17050
|
+
for (let index = 0; index + 1 < list.length; index += 2) {
|
|
17051
|
+
if (list[index] === "accept") return list[index + 1] ?? "";
|
|
17052
|
+
}
|
|
17053
|
+
return "";
|
|
17054
|
+
}
|
|
17055
|
+
async function readBack(ctx, record, objectId) {
|
|
17056
|
+
const answer = await ctx.cdp.send(record.view.webContents, "Runtime.callFunctionOn", {
|
|
17057
|
+
objectId,
|
|
17058
|
+
functionDeclaration: READ_FILES_FUNCTION,
|
|
17059
|
+
returnByValue: true
|
|
17060
|
+
});
|
|
17061
|
+
return answer?.result?.value ?? null;
|
|
17062
|
+
}
|
|
17063
|
+
function assertHolds(paths, held, selector, accept) {
|
|
17064
|
+
const expected = paths.map((path2) => path$1.basename(path2));
|
|
17065
|
+
const mismatch = held.count !== expected.length || expected.some((name, index) => held.names[index] !== name) || paths.some((path2, index) => sizeOf(path2) !== held.sizes[index]);
|
|
17066
|
+
if (!mismatch) return;
|
|
17067
|
+
throw new BridgeCommandError(
|
|
17068
|
+
"internal",
|
|
17069
|
+
`the file input did not take the files: it holds ${summarise(held)}, and ${expected.length} were attached (${expected.join(", ")})`,
|
|
17070
|
+
{
|
|
17071
|
+
selector,
|
|
17072
|
+
attached: expected,
|
|
17073
|
+
held: held.names,
|
|
17074
|
+
accept,
|
|
17075
|
+
reason: "read_back_mismatch"
|
|
17076
|
+
}
|
|
17077
|
+
);
|
|
17078
|
+
}
|
|
17079
|
+
function summarise(held) {
|
|
17080
|
+
if (held.count === 0) return "nothing";
|
|
17081
|
+
return `${held.count} file(s) [${held.names.join(", ")}]`;
|
|
17082
|
+
}
|
|
17083
|
+
function sizeOf(path2) {
|
|
17084
|
+
try {
|
|
17085
|
+
return fs.statSync(path2).size;
|
|
17086
|
+
} catch {
|
|
17087
|
+
return -1;
|
|
17088
|
+
}
|
|
17089
|
+
}
|
|
17090
|
+
function siteOf(url) {
|
|
17091
|
+
try {
|
|
17092
|
+
return new URL(url).host;
|
|
17093
|
+
} catch {
|
|
17094
|
+
return "";
|
|
17095
|
+
}
|
|
17096
|
+
}
|
|
17097
|
+
function factOf(path2) {
|
|
17098
|
+
return {
|
|
17099
|
+
name: path$1.basename(path2),
|
|
17100
|
+
path: path2,
|
|
17101
|
+
bytes: sizeOf(path2),
|
|
17102
|
+
mime: "",
|
|
17103
|
+
sniffed: "",
|
|
17104
|
+
sha256: ""
|
|
17105
|
+
};
|
|
17106
|
+
}
|
|
15967
17107
|
const DOCUMENT_SCOPED = /* @__PURE__ */ new Set([
|
|
15968
17108
|
"read",
|
|
15969
17109
|
"snapshot",
|
|
@@ -15971,7 +17111,23 @@ const DOCUMENT_SCOPED = /* @__PURE__ */ new Set([
|
|
|
15971
17111
|
"click",
|
|
15972
17112
|
"type",
|
|
15973
17113
|
"scroll",
|
|
15974
|
-
"logs"
|
|
17114
|
+
"logs",
|
|
17115
|
+
// `download` names a control on the CURRENT document (the link or button that
|
|
17116
|
+
// starts the file) and `upload` names a file input on it, so both are authorized
|
|
17117
|
+
// against that document on entry and again on the result. BOTH are in the
|
|
17118
|
+
// NAVIGATING_ACTIONS set below — each reaches its own control by driving the page,
|
|
17119
|
+
// and an auto-submitting form turns an attach into a navigation — and NEITHER is
|
|
17120
|
+
// in NAVIGATION_ACTIONS (the per-hop gate stays off for both); the set comments
|
|
17121
|
+
// say why, and the pair is what replaced the single post-hoc check the round-1
|
|
17122
|
+
// review refused (M2).
|
|
17123
|
+
"download",
|
|
17124
|
+
"upload"
|
|
17125
|
+
]);
|
|
17126
|
+
const NAVIGATING_ACTIONS = /* @__PURE__ */ new Set([
|
|
17127
|
+
"click",
|
|
17128
|
+
"type",
|
|
17129
|
+
"download",
|
|
17130
|
+
"upload"
|
|
15975
17131
|
]);
|
|
15976
17132
|
const NAVIGATION_ACTIONS = /* @__PURE__ */ new Set(["click", "type"]);
|
|
15977
17133
|
const TAB_SCOPED = /* @__PURE__ */ new Set([
|
|
@@ -15984,7 +17140,14 @@ const TAB_SCOPED = /* @__PURE__ */ new Set([
|
|
|
15984
17140
|
"scroll",
|
|
15985
17141
|
"logs",
|
|
15986
17142
|
"close",
|
|
15987
|
-
"retitle"
|
|
17143
|
+
"retitle",
|
|
17144
|
+
// The two file verbs take the tab's lane for the same reason every other
|
|
17145
|
+
// tab-addressed action does, and for one more: `download` ARMS the tab for the
|
|
17146
|
+
// duration of the call, and two concurrent arms on one tab would be two captures
|
|
17147
|
+
// writing into two harness directories with no way to tell which file came from
|
|
17148
|
+
// which call.
|
|
17149
|
+
"download",
|
|
17150
|
+
"upload"
|
|
15988
17151
|
]);
|
|
15989
17152
|
const RESTORE_CONCURRENCY = 4;
|
|
15990
17153
|
const RESTORE_TAB_TIMEOUT_MS = 1e4;
|
|
@@ -16021,6 +17184,7 @@ class BrowserHost {
|
|
|
16021
17184
|
cdp;
|
|
16022
17185
|
approvals;
|
|
16023
17186
|
ownership;
|
|
17187
|
+
downloads;
|
|
16024
17188
|
log;
|
|
16025
17189
|
onChanged;
|
|
16026
17190
|
facts;
|
|
@@ -16044,6 +17208,7 @@ class BrowserHost {
|
|
|
16044
17208
|
this.cdp = options.cdp;
|
|
16045
17209
|
this.approvals = options.approvals;
|
|
16046
17210
|
this.ownership = options.ownership;
|
|
17211
|
+
this.downloads = options.downloads;
|
|
16047
17212
|
this.log = options.log;
|
|
16048
17213
|
this.onChanged = options.onChanged;
|
|
16049
17214
|
this.facts = options.facts;
|
|
@@ -16069,10 +17234,11 @@ class BrowserHost {
|
|
|
16069
17234
|
const record = this.registry.requireSurface(token);
|
|
16070
17235
|
const requester = requesterOf(params, requestId2);
|
|
16071
17236
|
let authorizedOn = record.documentEpoch;
|
|
17237
|
+
const entryUrl = record.view.webContents.getURL();
|
|
16072
17238
|
const assertDocument = () => {
|
|
16073
17239
|
const url = new URL(record.view.webContents.getURL() || "about:blank");
|
|
16074
17240
|
if (record.documentEpoch !== authorizedOn) {
|
|
16075
|
-
if (!
|
|
17241
|
+
if (!NAVIGATING_ACTIONS.has(method)) {
|
|
16076
17242
|
this.registry.bumpEpoch(record.tabId);
|
|
16077
17243
|
throw new BridgeCommandError(
|
|
16078
17244
|
"origin_not_allowed",
|
|
@@ -16084,6 +17250,9 @@ class BrowserHost {
|
|
|
16084
17250
|
}
|
|
16085
17251
|
if (!permittedScheme(url) || !this.approvals.documentAllowed(token, url, requester, authorizedOn)) {
|
|
16086
17252
|
this.registry.bumpEpoch(record.tabId);
|
|
17253
|
+
if (NAVIGATING_ACTIONS.has(method) && url.href !== entryUrl) {
|
|
17254
|
+
this.restoreApprovedDocument(record, entryUrl);
|
|
17255
|
+
}
|
|
16087
17256
|
this.approvals.refuseDocument(url);
|
|
16088
17257
|
}
|
|
16089
17258
|
return url;
|
|
@@ -16109,6 +17278,22 @@ class BrowserHost {
|
|
|
16109
17278
|
operation
|
|
16110
17279
|
);
|
|
16111
17280
|
}
|
|
17281
|
+
/** Put a tab back on the document a call entered on, after that call's work
|
|
17282
|
+
* landed it somewhere it may not hold.
|
|
17283
|
+
*
|
|
17284
|
+
* The restore is a plain load of an already-authorized URL, and it is deliberately
|
|
17285
|
+
* NOT awaited: the caller is on its way to a thrown refusal, and the answer the
|
|
17286
|
+
* model reads is about the refusal rather than about the repaint. A view that died
|
|
17287
|
+
* under the call needs no restore and must not turn into a second error. */
|
|
17288
|
+
restoreApprovedDocument(record, url) {
|
|
17289
|
+
if (!url || record.view.webContents.isDestroyed()) return;
|
|
17290
|
+
const restore = record.view.webContents.loadURL(url);
|
|
17291
|
+
void Promise.resolve(restore).catch((error) => {
|
|
17292
|
+
this.log(
|
|
17293
|
+
`[browser] could not restore tab ${record.tabId} to ${url} after refusing a navigation: ${String(error)}`
|
|
17294
|
+
);
|
|
17295
|
+
});
|
|
17296
|
+
}
|
|
16112
17297
|
async perform(method, params, requestId2) {
|
|
16113
17298
|
switch (method) {
|
|
16114
17299
|
case "open":
|
|
@@ -16137,6 +17322,10 @@ class BrowserHost {
|
|
|
16137
17322
|
return click(this, params);
|
|
16138
17323
|
case "type":
|
|
16139
17324
|
return type(this, params);
|
|
17325
|
+
case "download":
|
|
17326
|
+
return download(this, params);
|
|
17327
|
+
case "upload":
|
|
17328
|
+
return upload(this, params);
|
|
16140
17329
|
case "request_access":
|
|
16141
17330
|
return this.approvals.requestAccess(
|
|
16142
17331
|
params.url,
|
|
@@ -16314,6 +17503,14 @@ class BrowserHost {
|
|
|
16314
17503
|
loading: this.tabLoading(entry.tabId)
|
|
16315
17504
|
})),
|
|
16316
17505
|
activeTabId: activeRecord?.tabId ?? null,
|
|
17506
|
+
// The download surface's facts (§16.4): what is in flight, where it went and
|
|
17507
|
+
// what was refused, so the chrome row renders from the host's own state rather
|
|
17508
|
+
// than from a second copy the renderer would have to keep in step. Shipped in
|
|
17509
|
+
// the same projection as the strip for the reason the approvals list is: one
|
|
17510
|
+
// subscription is one thing that can go stale. FOR ONE TAB (the active one),
|
|
17511
|
+
// which is the round-1 D2 fix: a host-wide projection rendered a decision taken
|
|
17512
|
+
// in another tab into this one's chrome.
|
|
17513
|
+
transfers: this.downloads.activityFor(active?.tabId ?? null),
|
|
16317
17514
|
url: active ? active.view.webContents.getURL() : "",
|
|
16318
17515
|
title: active ? this.titleForChrome(active.view.webContents.getTitle()) : "",
|
|
16319
17516
|
loading: active ? active.view.webContents.isLoading() : false,
|
|
@@ -16733,7 +17930,8 @@ const BROWSER_IPC_CHANNELS = [
|
|
|
16733
17930
|
"browser-forget-site",
|
|
16734
17931
|
"browser-clear-data",
|
|
16735
17932
|
"browser-webauthn-respond",
|
|
16736
|
-
"browser-webauthn-pending"
|
|
17933
|
+
"browser-webauthn-pending",
|
|
17934
|
+
"browser-reveal-downloads"
|
|
16737
17935
|
];
|
|
16738
17936
|
function registerBrowserIpc(options) {
|
|
16739
17937
|
function authorize(event) {
|
|
@@ -16862,6 +18060,15 @@ function registerBrowserIpc(options) {
|
|
|
16862
18060
|
options.log(`[browser] cleared browsing data: ${what}`);
|
|
16863
18061
|
return { cleared: what };
|
|
16864
18062
|
});
|
|
18063
|
+
electron.ipcMain.handle("browser-reveal-downloads", async (event) => {
|
|
18064
|
+
authorize(event);
|
|
18065
|
+
const message = await options.revealDownloads();
|
|
18066
|
+
if (message)
|
|
18067
|
+
options.log(
|
|
18068
|
+
`[browser] could not reveal the download directory: ${message}`
|
|
18069
|
+
);
|
|
18070
|
+
return { opened: message === "", message };
|
|
18071
|
+
});
|
|
16865
18072
|
}
|
|
16866
18073
|
function unregisterBrowserIpc() {
|
|
16867
18074
|
for (const channel of BROWSER_IPC_CHANNELS) electron.ipcMain.removeHandler(channel);
|
|
@@ -17250,15 +18457,26 @@ function installBrowserSessionHandlers(browserSession, hooks = {}) {
|
|
|
17250
18457
|
return false;
|
|
17251
18458
|
}
|
|
17252
18459
|
);
|
|
17253
|
-
browserSession.on("will-download", (event, item) => {
|
|
17254
|
-
|
|
17255
|
-
hooks.
|
|
17256
|
-
|
|
17257
|
-
|
|
17258
|
-
}
|
|
17259
|
-
|
|
17260
|
-
|
|
17261
|
-
|
|
18460
|
+
browserSession.on("will-download", (event, item, webContents) => {
|
|
18461
|
+
const webContentsId = webContents?.id ?? -1;
|
|
18462
|
+
const decision = hooks.onDownload?.(item, webContentsId) ?? {
|
|
18463
|
+
cancel: true,
|
|
18464
|
+
reason: "background downloads are not supported"
|
|
18465
|
+
};
|
|
18466
|
+
if (decision.cancel) event.preventDefault();
|
|
18467
|
+
const outcome = {
|
|
18468
|
+
webContentsId,
|
|
18469
|
+
url: item.getURL(),
|
|
18470
|
+
filename: item.getFilename(),
|
|
18471
|
+
savePath: decision.cancel ? null : item.getSavePath(),
|
|
18472
|
+
reason: decision.reason
|
|
18473
|
+
};
|
|
18474
|
+
if (decision.cancel) {
|
|
18475
|
+
hooks.log?.(
|
|
18476
|
+
`[browser] cancelled a download from ${outcome.url}: ${decision.reason}`
|
|
18477
|
+
);
|
|
18478
|
+
}
|
|
18479
|
+
hooks.onDownloadDecided?.(outcome);
|
|
17262
18480
|
});
|
|
17263
18481
|
}
|
|
17264
18482
|
async function clearBrowsingData(browserSession, what) {
|
|
@@ -17440,7 +18658,12 @@ async function handle(req, res, options) {
|
|
|
17440
18658
|
// rest, and a newer one learns whether the console is up without a
|
|
17441
18659
|
// second probe (design 10.1). `false` when nobody reported a
|
|
17442
18660
|
// capability, which is the honest answer for a host that has none.
|
|
17443
|
-
console: options.capabilities?.().console ?? false
|
|
18661
|
+
console: options.capabilities?.().console ?? false,
|
|
18662
|
+
// Additive, and the SECOND reader of the same fact (§6.3): a session that
|
|
18663
|
+
// finds a record it cannot trust asks here, and an old reader ignores the
|
|
18664
|
+
// key. It names what this build SERVES, which is what makes a typed
|
|
18665
|
+
// `capability_unsupported` possible without opening a socket.
|
|
18666
|
+
capabilities: [...HOST_CAPABILITIES]
|
|
17444
18667
|
};
|
|
17445
18668
|
send(res, 200, body);
|
|
17446
18669
|
return;
|
|
@@ -18562,14 +19785,34 @@ function browserHostEnabled(env = process.env) {
|
|
|
18562
19785
|
async function startBrowserHost(options) {
|
|
18563
19786
|
const { log } = options;
|
|
18564
19787
|
const browserSession = resolveBrowserSession();
|
|
19788
|
+
let registryForDownloads = null;
|
|
19789
|
+
const downloads = new DownloadArmer({
|
|
19790
|
+
tabForWebContents: (webContentsId) => registryForDownloads?.byWebContents(webContentsId)?.tabId ?? null,
|
|
19791
|
+
// WHO OWNED THE TAB, recorded on the decision (review round 2, U10). Read from
|
|
19792
|
+
// the registry at the moment the note is written, because the row may render it
|
|
19793
|
+
// after the tab is closed — and "· on another tab" about a tab that no longer
|
|
19794
|
+
// exists is a marker pointing at nothing. `null` when the lookup cannot answer
|
|
19795
|
+
// (a tab already gone), which the row renders the old way rather than guessing.
|
|
19796
|
+
ownerKindFor: (tabId) => registryForDownloads?.get(tabId)?.owner ?? null,
|
|
19797
|
+
log,
|
|
19798
|
+
onActivity: () => {
|
|
19799
|
+
if (options.window.isDestroyed()) return;
|
|
19800
|
+
options.window.webContents.send("browser-state-changed");
|
|
19801
|
+
}
|
|
19802
|
+
});
|
|
18565
19803
|
installBrowserSessionHandlers(browserSession, {
|
|
18566
19804
|
onPermissionRequested: (details) => {
|
|
18567
19805
|
log(
|
|
18568
19806
|
`[browser] denied a ${details.permission} permission request from ${details.origin || "(unknown origin)"}`
|
|
18569
19807
|
);
|
|
18570
19808
|
},
|
|
18571
|
-
|
|
18572
|
-
|
|
19809
|
+
onDownload: (item, webContentsId) => downloads.decide(item, webContentsId),
|
|
19810
|
+
onDownloadDecided: (outcome) => {
|
|
19811
|
+
if (outcome.savePath) {
|
|
19812
|
+
log(
|
|
19813
|
+
`[browser] saved a download from ${outcome.url} to ${outcome.savePath}`
|
|
19814
|
+
);
|
|
19815
|
+
}
|
|
18573
19816
|
},
|
|
18574
19817
|
log
|
|
18575
19818
|
});
|
|
@@ -18668,9 +19911,11 @@ async function startBrowserHost(options) {
|
|
|
18668
19911
|
releaseTab,
|
|
18669
19912
|
notifyChanged
|
|
18670
19913
|
);
|
|
19914
|
+
registryForDownloads = registry;
|
|
18671
19915
|
function releaseTab(tabId, webContentsId) {
|
|
18672
19916
|
const view = views.get(tabId);
|
|
18673
19917
|
views.delete(tabId);
|
|
19918
|
+
downloads.forget(tabId);
|
|
18674
19919
|
void cdp.detach(webContentsId).finally(() => {
|
|
18675
19920
|
releaseView(options.window, view);
|
|
18676
19921
|
});
|
|
@@ -18720,6 +19965,7 @@ async function startBrowserHost(options) {
|
|
|
18720
19965
|
cdp,
|
|
18721
19966
|
approvals,
|
|
18722
19967
|
ownership,
|
|
19968
|
+
downloads,
|
|
18723
19969
|
log,
|
|
18724
19970
|
onChanged: notifyChanged,
|
|
18725
19971
|
facts: () => facts,
|
|
@@ -18782,6 +20028,17 @@ async function startBrowserHost(options) {
|
|
|
18782
20028
|
host: () => host,
|
|
18783
20029
|
webauthn: () => webauthn,
|
|
18784
20030
|
clearData: (what) => sessionCookies.clearBrowsingData(what),
|
|
20031
|
+
// THE REVEAL TAKES NO PATH FROM THE RENDERER (§16.4): it opens the directory the
|
|
20032
|
+
// host is actually writing into, so the one place a page-derived string could
|
|
20033
|
+
// have become a path stays out of the IPC surface as well. `shell.openPath`
|
|
20034
|
+
// answers "" on success and a message on failure, which is returned rather than
|
|
20035
|
+
// thrown: a reveal that fails is a Finder problem, not a fault in the agent's
|
|
20036
|
+
// download.
|
|
20037
|
+
revealDownloads: async () => {
|
|
20038
|
+
const dir = downloads.downloadDir();
|
|
20039
|
+
if (!dir) return "no download directory yet";
|
|
20040
|
+
return electron.shell.openPath(dir);
|
|
20041
|
+
},
|
|
18785
20042
|
log
|
|
18786
20043
|
});
|
|
18787
20044
|
log(
|
|
@@ -20610,6 +21867,232 @@ function clauseTextOf(message, start, end) {
|
|
|
20610
21867
|
function stripErrorPrefixes(message) {
|
|
20611
21868
|
return message.replace(LEADING_ERROR_PREFIXES, "").replace(EMBEDDED_ERROR_PREFIX, ": ");
|
|
20612
21869
|
}
|
|
21870
|
+
const FLEET_DRAIN_BUDGET_MS = 6e5;
|
|
21871
|
+
const FLEET_DRAIN_POLL_MS = 5e3;
|
|
21872
|
+
const FLEET_RETIRE_GRACE_MS = 6e4;
|
|
21873
|
+
const FLEET_RETIRE_SETTLE_MS = 3e4;
|
|
21874
|
+
async function waitForFleetIdle(input) {
|
|
21875
|
+
const budgetMs = input.budgetMs ?? FLEET_DRAIN_BUDGET_MS;
|
|
21876
|
+
const pollMs = input.pollMs ?? FLEET_DRAIN_POLL_MS;
|
|
21877
|
+
const startedAt = input.now();
|
|
21878
|
+
let workState = await input.readWorkState();
|
|
21879
|
+
let waitedMs = 0;
|
|
21880
|
+
while (workState !== "idle") {
|
|
21881
|
+
waitedMs = input.now() - startedAt;
|
|
21882
|
+
if (waitedMs >= budgetMs) {
|
|
21883
|
+
const unknown = workState === "unknown";
|
|
21884
|
+
const refusedCredentials = unknown && input.readUnreadableReason?.() === "refused-credentials";
|
|
21885
|
+
return {
|
|
21886
|
+
kind: "refused",
|
|
21887
|
+
because: unknown ? "unknown" : "busy",
|
|
21888
|
+
waitedMs,
|
|
21889
|
+
busy: busyRosterRows(await input.readRoster() ?? []),
|
|
21890
|
+
credentialsRefused: refusedCredentials
|
|
21891
|
+
};
|
|
21892
|
+
}
|
|
21893
|
+
input.onWait?.(waitedMs, workState);
|
|
21894
|
+
await input.sleep(Math.min(pollMs, budgetMs - waitedMs));
|
|
21895
|
+
workState = await input.readWorkState();
|
|
21896
|
+
}
|
|
21897
|
+
return {
|
|
21898
|
+
kind: "drained",
|
|
21899
|
+
waitedMs: input.now() - startedAt,
|
|
21900
|
+
fleet: (await input.readRoster())?.length ?? 0
|
|
21901
|
+
};
|
|
21902
|
+
}
|
|
21903
|
+
function fleetDrainRefusalSentence(outcome, installLanded = false) {
|
|
21904
|
+
const minutes = Math.max(1, Math.round(outcome.waitedMs / 6e4));
|
|
21905
|
+
const waited = `${minutes} minute${minutes === 1 ? "" : "s"}`;
|
|
21906
|
+
const closing = installLanded ? "The install itself has landed, and the server keeps running the build it loaded until it can restart onto it; the app will offer this update again." : "Nothing was installed and the server keeps running the build it loaded; the app will offer this update again.";
|
|
21907
|
+
if (outcome.because === "unknown") {
|
|
21908
|
+
const lead2 = outcome.credentialsRefused ? "The server refused this app's credentials, so the app could not read which sessions are running on this machine." : "The app could not read which sessions are running on this machine, so it did not update the server.";
|
|
21909
|
+
return `${lead2}
|
|
21910
|
+
|
|
21911
|
+
Reading an unreadable fleet as idle could cut off a turn that is in flight, so the app waited ${waited} and then stopped. ${closing}`;
|
|
21912
|
+
}
|
|
21913
|
+
const names = outcome.busy.slice(0, 3).map((row) => row.name || row.sessionId).join(", ");
|
|
21914
|
+
const more = outcome.busy.length > 3 ? ` and ${outcome.busy.length - 3} more` : "";
|
|
21915
|
+
const lead = outcome.busy.length === 0 ? "The app could not name the sessions that were still working." : `${outcome.busy.length} session${outcome.busy.length === 1 ? " is" : "s are"} still running a turn on this machine: ${names}${more}.`;
|
|
21916
|
+
return `${lead}
|
|
21917
|
+
|
|
21918
|
+
The app waited ${waited} for them to finish and then stopped rather than cut a turn short. ${closing}`;
|
|
21919
|
+
}
|
|
21920
|
+
const isLiveRow = (row) => row.liveState !== "" && row.liveState !== null && row.sessionId !== "";
|
|
21921
|
+
const sessionHasRuntime = (rows, sessionId2) => {
|
|
21922
|
+
if (rows === null) return null;
|
|
21923
|
+
return rows.some((row) => row.sessionId === sessionId2 && isLiveRow(row));
|
|
21924
|
+
};
|
|
21925
|
+
const liveRosterIds = (rows) => {
|
|
21926
|
+
if (rows === null) return null;
|
|
21927
|
+
return new Set(rows.filter(isLiveRow).map((row) => row.sessionId));
|
|
21928
|
+
};
|
|
21929
|
+
function displacedSessions(before, after) {
|
|
21930
|
+
const liveNow = liveRosterIds(after);
|
|
21931
|
+
if (liveNow === null) return [];
|
|
21932
|
+
return before.filter((row) => isLiveRow(row) && !liveNow.has(row.sessionId));
|
|
21933
|
+
}
|
|
21934
|
+
function unionFleetSnapshots(first, second) {
|
|
21935
|
+
if (first === null) return second === null ? null : [...second];
|
|
21936
|
+
if (second === null) return [...first];
|
|
21937
|
+
const seen = new Set(first.map((row) => row.sessionId));
|
|
21938
|
+
return [...first, ...second.filter((row) => !seen.has(row.sessionId))];
|
|
21939
|
+
}
|
|
21940
|
+
async function reengageDisplacedSessions(input) {
|
|
21941
|
+
const graceMs = input.graceMs ?? FLEET_RETIRE_GRACE_MS;
|
|
21942
|
+
const settleMs = input.settleMs ?? FLEET_RETIRE_SETTLE_MS;
|
|
21943
|
+
const pollMs = input.retirePollMs ?? FLEET_DRAIN_POLL_MS;
|
|
21944
|
+
const startedAt = input.now();
|
|
21945
|
+
const snapshot2 = input.before.filter(isLiveRow);
|
|
21946
|
+
if (snapshot2.length === 0)
|
|
21947
|
+
return { displaced: [], engaged: [], failed: [], stillResident: [] };
|
|
21948
|
+
const snapshotIds = snapshot2.map((row) => row.sessionId);
|
|
21949
|
+
const stillLiveIn = (rows) => {
|
|
21950
|
+
const live = liveRosterIds(rows);
|
|
21951
|
+
if (live === null) return null;
|
|
21952
|
+
return new Set(snapshotIds.filter((id2) => live.has(id2)));
|
|
21953
|
+
};
|
|
21954
|
+
let after = await input.readRoster();
|
|
21955
|
+
let stillLive = stillLiveIn(after) ?? new Set(snapshotIds);
|
|
21956
|
+
let lastChangeAt = input.now();
|
|
21957
|
+
let waveMoved = stillLive.size < snapshotIds.length;
|
|
21958
|
+
for (; ; ) {
|
|
21959
|
+
const elapsed = input.now() - startedAt;
|
|
21960
|
+
if (elapsed >= graceMs) break;
|
|
21961
|
+
if (stillLive.size === 0) break;
|
|
21962
|
+
if (waveMoved && input.now() - lastChangeAt >= settleMs) break;
|
|
21963
|
+
await input.sleep(Math.min(pollMs, Math.max(1, graceMs - elapsed)));
|
|
21964
|
+
after = await input.readRoster();
|
|
21965
|
+
const next = stillLiveIn(after);
|
|
21966
|
+
if (next === null) continue;
|
|
21967
|
+
if (next.size !== stillLive.size || [...next].some((id2) => !stillLive.has(id2))) {
|
|
21968
|
+
stillLive = next;
|
|
21969
|
+
lastChangeAt = input.now();
|
|
21970
|
+
waveMoved = true;
|
|
21971
|
+
}
|
|
21972
|
+
}
|
|
21973
|
+
const finalRead = await input.readRoster();
|
|
21974
|
+
if (finalRead !== null) after = finalRead;
|
|
21975
|
+
const displaced = displacedSessions(input.before, after);
|
|
21976
|
+
const stillResident = snapshot2.filter((row) => stillLive.has(row.sessionId));
|
|
21977
|
+
const engaged = [];
|
|
21978
|
+
const failed = [];
|
|
21979
|
+
for (const row of displaced) {
|
|
21980
|
+
try {
|
|
21981
|
+
if (await input.engage(row)) engaged.push(row.sessionId);
|
|
21982
|
+
else
|
|
21983
|
+
failed.push({
|
|
21984
|
+
sessionId: row.sessionId,
|
|
21985
|
+
reason: "the server did not take the engage"
|
|
21986
|
+
});
|
|
21987
|
+
} catch (error) {
|
|
21988
|
+
failed.push({
|
|
21989
|
+
sessionId: row.sessionId,
|
|
21990
|
+
reason: error instanceof Error ? error.message : String(error)
|
|
21991
|
+
});
|
|
21992
|
+
}
|
|
21993
|
+
}
|
|
21994
|
+
input.log?.(
|
|
21995
|
+
`Re-engaged ${engaged.length} of ${displaced.length} displaced session(s)${failed.length > 0 ? `; ${failed.length} did not answer (${failed.map((row) => row.sessionId).join(", ")})` : ""}${stillResident.length > 0 ? `; ${stillResident.length} pre-swap runtime(s) still resident when the wait ended (${stillResident.map((row) => row.sessionId).join(", ")}), which nothing re-engages afterwards and which go cold on their own schedule` : ""}`
|
|
21996
|
+
);
|
|
21997
|
+
return { displaced, engaged, failed, stillResident };
|
|
21998
|
+
}
|
|
21999
|
+
const SESSION_ENGAGE_OPEN_MS = 5e3;
|
|
22000
|
+
const SESSION_ENGAGE_BEAT_MS = 5e3;
|
|
22001
|
+
const SESSION_ENGAGE_HOLD_MS = 2e4;
|
|
22002
|
+
const subscriptionIdIn = (frame) => {
|
|
22003
|
+
try {
|
|
22004
|
+
const parsed = JSON.parse(frame);
|
|
22005
|
+
if (typeof parsed !== "object" || parsed === null) return null;
|
|
22006
|
+
const payload = parsed.payload;
|
|
22007
|
+
if (typeof payload !== "object" || payload === null) return null;
|
|
22008
|
+
const id2 = payload.subscription_id;
|
|
22009
|
+
return typeof id2 === "string" && id2 !== "" ? id2 : null;
|
|
22010
|
+
} catch {
|
|
22011
|
+
return null;
|
|
22012
|
+
}
|
|
22013
|
+
};
|
|
22014
|
+
async function engageSessionThroughStream(input) {
|
|
22015
|
+
const openMs = input.openMs ?? SESSION_ENGAGE_OPEN_MS;
|
|
22016
|
+
const beatMs = input.beatMs ?? SESSION_ENGAGE_BEAT_MS;
|
|
22017
|
+
const holdMs = input.holdMs ?? SESSION_ENGAGE_HOLD_MS;
|
|
22018
|
+
const startedAt = input.now();
|
|
22019
|
+
let subscriptionId = null;
|
|
22020
|
+
let streamError = null;
|
|
22021
|
+
const handle2 = input.subscribe(input.sessionId, (event) => {
|
|
22022
|
+
if (event.kind === "data") {
|
|
22023
|
+
subscriptionId ??= subscriptionIdIn(event.data);
|
|
22024
|
+
return;
|
|
22025
|
+
}
|
|
22026
|
+
if (event.kind === "error") streamError ??= event.detail;
|
|
22027
|
+
else streamError ??= "the session's events stream ended";
|
|
22028
|
+
});
|
|
22029
|
+
try {
|
|
22030
|
+
while (subscriptionId === null && input.now() - startedAt < openMs) {
|
|
22031
|
+
if (streamError !== null) break;
|
|
22032
|
+
await input.sleep(
|
|
22033
|
+
Math.min(beatMs, Math.max(1, openMs - (input.now() - startedAt)))
|
|
22034
|
+
);
|
|
22035
|
+
}
|
|
22036
|
+
if (subscriptionId === null) {
|
|
22037
|
+
return {
|
|
22038
|
+
engaged: false,
|
|
22039
|
+
reason: streamError ?? "the session's events stream never announced a subscription",
|
|
22040
|
+
beats: 0
|
|
22041
|
+
};
|
|
22042
|
+
}
|
|
22043
|
+
const lease = await input.watch(subscriptionId);
|
|
22044
|
+
if (lease.status !== 200) {
|
|
22045
|
+
return {
|
|
22046
|
+
engaged: false,
|
|
22047
|
+
reason: `the lease answered ${lease.status}`,
|
|
22048
|
+
beats: 0
|
|
22049
|
+
};
|
|
22050
|
+
}
|
|
22051
|
+
const warm = await input.warm(input.sessionId);
|
|
22052
|
+
if (warm.status !== 200) {
|
|
22053
|
+
input.log?.(
|
|
22054
|
+
`The warm for ${input.sessionId} answered ${warm.status}; the lease is still held while the runtime comes up`
|
|
22055
|
+
);
|
|
22056
|
+
}
|
|
22057
|
+
let beats = 0;
|
|
22058
|
+
let cameUp = false;
|
|
22059
|
+
let leaseHeld = true;
|
|
22060
|
+
const leaseAt = input.now();
|
|
22061
|
+
while (input.now() - startedAt < openMs + holdMs) {
|
|
22062
|
+
const live = await input.hasRuntime();
|
|
22063
|
+
if (live === true) {
|
|
22064
|
+
cameUp = true;
|
|
22065
|
+
break;
|
|
22066
|
+
}
|
|
22067
|
+
await input.sleep(
|
|
22068
|
+
Math.min(
|
|
22069
|
+
beatMs,
|
|
22070
|
+
Math.max(1, openMs + holdMs - (input.now() - startedAt))
|
|
22071
|
+
)
|
|
22072
|
+
);
|
|
22073
|
+
if (streamError !== null) {
|
|
22074
|
+
if (leaseHeld) {
|
|
22075
|
+
leaseHeld = false;
|
|
22076
|
+
input.log?.(
|
|
22077
|
+
`The lease for ${input.sessionId} was withdrawn when its stream ended (${streamError}); the roster read still decides whether a runtime came up`
|
|
22078
|
+
);
|
|
22079
|
+
}
|
|
22080
|
+
continue;
|
|
22081
|
+
}
|
|
22082
|
+
await input.watch(subscriptionId);
|
|
22083
|
+
beats += 1;
|
|
22084
|
+
}
|
|
22085
|
+
if (cameUp) return { engaged: true, beats };
|
|
22086
|
+
const heldSeconds = Math.round((input.now() - leaseAt) / 1e3);
|
|
22087
|
+
return {
|
|
22088
|
+
engaged: false,
|
|
22089
|
+
reason: streamError === null ? `no runtime for ${input.sessionId} ${heldSeconds}s after the lease` : `the session's events stream ended ${heldSeconds}s into the hold (${streamError}), so the lease was withdrawn before the runtime could be asked for`,
|
|
22090
|
+
beats
|
|
22091
|
+
};
|
|
22092
|
+
} finally {
|
|
22093
|
+
input.unsubscribe(handle2.streamId);
|
|
22094
|
+
}
|
|
22095
|
+
}
|
|
20613
22096
|
const GROUP_SIGNAL_GRACE_MS = 5e3;
|
|
20614
22097
|
const GROUP_EXIT_WAIT_MS = 2e4;
|
|
20615
22098
|
function isInstallGroupAlive(pid) {
|
|
@@ -20936,8 +22419,8 @@ function readPlistValue(plistPath, key) {
|
|
|
20936
22419
|
}
|
|
20937
22420
|
function readStagedBundle(appPath) {
|
|
20938
22421
|
const plistPath = path$1.join(appPath, "Contents", "Info.plist");
|
|
20939
|
-
const
|
|
20940
|
-
const executablePath =
|
|
22422
|
+
const executableName2 = readPlistValue(plistPath, "CFBundleExecutable");
|
|
22423
|
+
const executablePath = executableName2 == null ? null : path$1.join(appPath, "Contents", "MacOS", executableName2);
|
|
20941
22424
|
const architectures2 = executablePath != null && fs.existsSync(executablePath) ? (readCommandOutput("/usr/bin/lipo", ["-archs", executablePath]) ?? "").split(ARCHITECTURE_SEPARATOR_REGEX).filter((entry) => entry.length > 0) : null;
|
|
20942
22425
|
return {
|
|
20943
22426
|
appPath,
|
|
@@ -21439,6 +22922,53 @@ class UpdateService {
|
|
|
21439
22922
|
* the handler).
|
|
21440
22923
|
*/
|
|
21441
22924
|
installPreflightInFlight = false;
|
|
22925
|
+
/**
|
|
22926
|
+
* How long an update press may hold back for the fleet, and how often it
|
|
22927
|
+
* re-reads it.
|
|
22928
|
+
*
|
|
22929
|
+
* FIELDS rather than the module's constants read in place, because both halves
|
|
22930
|
+
* are waited on inside one press and the harness has to be able to drive a wait
|
|
22931
|
+
* in milliseconds instead of ten minutes (`scripts/update-robustness.test.mjs`,
|
|
22932
|
+
* the same lever `waitForBackendVersion` and the install budgets already are).
|
|
22933
|
+
* The values they start at are the host tool's own, in
|
|
22934
|
+
* `backend/fleet-drain.ts`, where the reasoning for the ten minutes lives.
|
|
22935
|
+
*/
|
|
22936
|
+
fleetDrainBudgetMs = FLEET_DRAIN_BUDGET_MS;
|
|
22937
|
+
fleetDrainPollMs = FLEET_DRAIN_POLL_MS;
|
|
22938
|
+
/**
|
|
22939
|
+
* How long a displaced runtime gets to retire on its own before the app puts
|
|
22940
|
+
* its successor back, and how long the pre-swap live set must hold still first.
|
|
22941
|
+
*
|
|
22942
|
+
* Fields for the reason the pair above are: a service-level case has to drive
|
|
22943
|
+
* the wait in milliseconds instead of the harness's real convergence window
|
|
22944
|
+
* (30 s of settle inside a 60 s grace), and the numbers themselves - with the
|
|
22945
|
+
* harness constants they come from - are argued in `backend/fleet-drain.ts`.
|
|
22946
|
+
*/
|
|
22947
|
+
fleetRetireGraceMs = FLEET_RETIRE_GRACE_MS;
|
|
22948
|
+
fleetRetireSettleMs = FLEET_RETIRE_SETTLE_MS;
|
|
22949
|
+
/**
|
|
22950
|
+
* The engage's own three bounds: how long the session's events stream may take
|
|
22951
|
+
* to announce its subscription, how often the held lease is renewed, and how
|
|
22952
|
+
* long the stream is held while the runtime comes up.
|
|
22953
|
+
*
|
|
22954
|
+
* Fields for the reason the four above are - a service-level case drives them in
|
|
22955
|
+
* milliseconds rather than paying twenty seconds per session - and the numbers
|
|
22956
|
+
* themselves, with the harness constants they come from, are argued in
|
|
22957
|
+
* `backend/session-engage.ts`.
|
|
22958
|
+
*/
|
|
22959
|
+
sessionEngageOpenMs = SESSION_ENGAGE_OPEN_MS;
|
|
22960
|
+
sessionEngageBeatMs = SESSION_ENGAGE_BEAT_MS;
|
|
22961
|
+
sessionEngageHoldMs = SESSION_ENGAGE_HOLD_MS;
|
|
22962
|
+
/**
|
|
22963
|
+
* How long the press IN FLIGHT has already spent waiting for the fleet.
|
|
22964
|
+
*
|
|
22965
|
+
* The budget above is the press's, not each drain's (review round 1, m2): a
|
|
22966
|
+
* rebuild press drains twice - once before the install and once before the
|
|
22967
|
+
* restart - so a per-drain budget let one press hold the button for twenty
|
|
22968
|
+
* minutes while the panel promised ten. Reset at the top of every press, in
|
|
22969
|
+
* `updateBackend`, because the guarantee is about one press.
|
|
22970
|
+
*/
|
|
22971
|
+
fleetDrainSpentMs = 0;
|
|
21442
22972
|
/**
|
|
21443
22973
|
* Initialize the update service
|
|
21444
22974
|
* @param mainWindow - The main application window
|
|
@@ -23994,8 +25524,14 @@ ${heal.removed.join("\n")}`,
|
|
|
23994
25524
|
* reader is talking to) was missing on the only arm whose press now
|
|
23995
25525
|
* publishes a generation and restarts a daemon. It is the managed global
|
|
23996
25526
|
* arm's own wording, aimed at this arm's mechanism.
|
|
25527
|
+
*
|
|
25528
|
+
* AND IT NO LONGER PROMISES A DROPPED TURN. It did, because it described
|
|
25529
|
+
* what the press used to do; the press now waits for the running turns to
|
|
25530
|
+
* finish before it moves the server (`drainFleetForUpdate`), so the honest
|
|
25531
|
+
* consequence is the wait and the sequence, with the cost stated as the
|
|
25532
|
+
* time it takes rather than as work it destroys.
|
|
23997
25533
|
*/
|
|
23998
|
-
remedy: "The app publishes
|
|
25534
|
+
remedy: "The app publishes the new build beside the one the server is using, waits for the turns already running on this machine to finish, and then moves the server onto it, so nothing in flight is cut off. This can take a minute or two.",
|
|
23999
25535
|
detail: `The app started this server itself (${startupMode}).`,
|
|
24000
25536
|
sourceBuild: false,
|
|
24001
25537
|
/*
|
|
@@ -24298,6 +25834,28 @@ ${heal.removed.join("\n")}`,
|
|
|
24298
25834
|
* other two events already carry. It travels here too.
|
|
24299
25835
|
*/
|
|
24300
25836
|
restartable: this.backendIsAppOwned(),
|
|
25837
|
+
/*
|
|
25838
|
+
* WHETHER THIS PRESS MOVES THE SERVER AT ALL, which is not the same
|
|
25839
|
+
* question as `restartable` above and is not answerable from it.
|
|
25840
|
+
* `restartable` says the daemon reading this app is one the app STARTED;
|
|
25841
|
+
* this says the press would RESTART it. On the harness's generation
|
|
25842
|
+
* layout the install lands in a tree no running process is reading, so
|
|
25843
|
+
* the app installs and announces and the server adopts the new build at
|
|
25844
|
+
* its own next idle - the cost sentence and the install phase's clause
|
|
25845
|
+
* both promised a bounce, and a promise the press does not keep is the
|
|
25846
|
+
* class of copy defect this panel has been reviewed for three times.
|
|
25847
|
+
*
|
|
25848
|
+
* THREE FACTS, and every one of them is already on this panel: the press
|
|
25849
|
+
* runs the app's own publish-and-restart only where the install it would
|
|
25850
|
+
* move is the app's OWN (`appOwnsInstall`, the reading below it); the
|
|
25851
|
+
* global `lop update` route does not bounce anything when its install is
|
|
25852
|
+
* a harness generation (`managedRoute`), and does when the route is the
|
|
25853
|
+
* in-place checkout rebuild; and a daemon this app did not start is never
|
|
25854
|
+
* the app's to move at all. The single case that restarts on a generation
|
|
25855
|
+
* install is a press with nothing left to install - the skew panel's own
|
|
25856
|
+
* control - which is a different offer entirely.
|
|
25857
|
+
*/
|
|
25858
|
+
restartsServer: this.backendIsAppOwned() && (this.appOwnsInstall(startupMode, serving) || plan.managedRoute !== "entry-point"),
|
|
24301
25859
|
/*
|
|
24302
25860
|
* AND WHOSE INSTALL A PRESS WOULD MOVE (design D5), which is the other
|
|
24303
25861
|
* ownership reading: `restartable` is true here on a global install too,
|
|
@@ -24644,6 +26202,259 @@ ${heal.removed.join("\n")}`,
|
|
|
24644
26202
|
backendIsAppOwned() {
|
|
24645
26203
|
return this.backendService?.servingInstall().owned.owned ?? false;
|
|
24646
26204
|
}
|
|
26205
|
+
/**
|
|
26206
|
+
* Wait for the fleet this app can see to go idle, or refuse the update.
|
|
26207
|
+
*
|
|
26208
|
+
* THE ONE GATE EVERY RESTART AND EVERY IN-PLACE INSTALL GOES THROUGH. A
|
|
26209
|
+
* restart of the server serving this app is `stop(true)` - SIGTERM, ten
|
|
26210
|
+
* seconds, SIGKILL - and the daemon's own `retire.py` declines that exit for
|
|
26211
|
+
* exactly this reason: its shutdown cancels work it owns. The operator's rule
|
|
26212
|
+
* is that nothing kills runtimes en masse, so the app waits for the fleet to
|
|
26213
|
+
* drain before it touches anything, and REFUSES with a remedy at the end of
|
|
26214
|
+
* its budget rather than cutting off a turn on a timer.
|
|
26215
|
+
*
|
|
26216
|
+
* WHY THE MANAGER'S OWN READERS. `servingWorkState` is the app's existing
|
|
26217
|
+
* busy signal (see `servingWorkStateFromSessions`) and `servingSessionFleet`
|
|
26218
|
+
* is the same route with the rows kept; this method adds the WAIT and the
|
|
26219
|
+
* refusal, not a second notion of what is running.
|
|
26220
|
+
*
|
|
26221
|
+
* A REFUSAL, NOT A FAILURE: it answers the press with the sentence that says
|
|
26222
|
+
* what the app was waiting for, on the same channel the other refusals use
|
|
26223
|
+
* (`backend-update-error`), and the install it guarded does not happen.
|
|
26224
|
+
*
|
|
26225
|
+
* @returns true when the fleet drained (or nothing is running in it), false
|
|
26226
|
+
* when the update was refused and the caller must not proceed
|
|
26227
|
+
*/
|
|
26228
|
+
async drainFleetForUpdate(input) {
|
|
26229
|
+
const { backend, what } = input;
|
|
26230
|
+
const spentMs = this.fleetDrainSpentMs;
|
|
26231
|
+
const remainingMs = Math.max(0, this.fleetDrainBudgetMs - spentMs);
|
|
26232
|
+
const outcome = await waitForFleetIdle({
|
|
26233
|
+
readWorkState: () => backend.servingWorkState(),
|
|
26234
|
+
readRoster: () => backend.servingSessionFleet(),
|
|
26235
|
+
// The transport's own reading of WHY an unreadable roster was
|
|
26236
|
+
// unreadable, read only if a refusal is composed from one.
|
|
26237
|
+
readUnreadableReason: () => backend.fleetReadFailureReason(),
|
|
26238
|
+
sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
26239
|
+
now: () => Date.now(),
|
|
26240
|
+
budgetMs: remainingMs,
|
|
26241
|
+
pollMs: this.fleetDrainPollMs,
|
|
26242
|
+
/*
|
|
26243
|
+
* The panel is told the update is WAITING, and it is told before the
|
|
26244
|
+
* first poll rather than after the tenth: a press that means to install
|
|
26245
|
+
* and instead sits still for minutes is the silence this panel's copy
|
|
26246
|
+
* exists to remove. The ELAPSED reading travels with it (design D3): a
|
|
26247
|
+
* ten-minute wait with nothing moving on the frame is indistinguishable
|
|
26248
|
+
* from a hung app, and `waitedMs` is the one number the app already has.
|
|
26249
|
+
*/
|
|
26250
|
+
onWait: (elapsedMs) => {
|
|
26251
|
+
this.sendToRenderer("backend-update-progress", {
|
|
26252
|
+
phase: "draining",
|
|
26253
|
+
waitedMs: spentMs + elapsedMs
|
|
26254
|
+
});
|
|
26255
|
+
logger.info(
|
|
26256
|
+
`Update waiting for the fleet to drain before it may ${what} (${Math.round(elapsedMs / 1e3)}s so far)`,
|
|
26257
|
+
LogFileType.UPDATE_SERVICE
|
|
26258
|
+
);
|
|
26259
|
+
}
|
|
26260
|
+
});
|
|
26261
|
+
this.fleetDrainSpentMs = spentMs + outcome.waitedMs;
|
|
26262
|
+
if (outcome.kind === "drained") {
|
|
26263
|
+
logger.info(
|
|
26264
|
+
`The fleet is idle (${outcome.fleet} session(s) on the roster, ${outcome.waitedMs}ms waited); the update may ${what}`,
|
|
26265
|
+
LogFileType.UPDATE_SERVICE
|
|
26266
|
+
);
|
|
26267
|
+
return true;
|
|
26268
|
+
}
|
|
26269
|
+
const message = fleetDrainRefusalSentence(
|
|
26270
|
+
outcome,
|
|
26271
|
+
input.installLanded === true
|
|
26272
|
+
);
|
|
26273
|
+
const command = input.command ?? null;
|
|
26274
|
+
const pressWaitedMs = spentMs + outcome.waitedMs;
|
|
26275
|
+
logger.warn(
|
|
26276
|
+
`Refusing to ${what}: the fleet did not drain in ${pressWaitedMs}ms (${outcome.because}, ${outcome.busy.length} mid-turn, this leg spent ${outcome.waitedMs}ms). ${message}${command ? ` By hand: ${command}` : ""}`,
|
|
26277
|
+
LogFileType.UPDATE_SERVICE
|
|
26278
|
+
);
|
|
26279
|
+
this.sendToRenderer("backend-update-error", {
|
|
26280
|
+
message,
|
|
26281
|
+
phase: "update",
|
|
26282
|
+
logPath: serverUpdateLogPath(),
|
|
26283
|
+
/*
|
|
26284
|
+
* WHAT WAS WAITED FOR, and the choice made instead of forcing: this is
|
|
26285
|
+
* what lets the panel paint a HELD-BACK update rather than a failed one
|
|
26286
|
+
* (review round 1, D1) and put the sessions it waited for, and the
|
|
26287
|
+
* command that skips the wait, where a reader can act on them.
|
|
26288
|
+
*/
|
|
26289
|
+
refusal: {
|
|
26290
|
+
because: outcome.because,
|
|
26291
|
+
waitedMs: pressWaitedMs,
|
|
26292
|
+
command,
|
|
26293
|
+
credentialsRefused: outcome.credentialsRefused === true,
|
|
26294
|
+
/*
|
|
26295
|
+
* WHICH REFUSAL THIS IS (design round 2, D6). The heading is the reader's
|
|
26296
|
+
* takeaway from a panel they have been looking at for ten minutes, and the
|
|
26297
|
+
* three refusal sites are not the same event: the install leg's refusal left
|
|
26298
|
+
* nothing on disk, while the two restart-leg refusals happen AFTER the build
|
|
26299
|
+
* landed. "The update didn't start" is false on the second pair - the
|
|
26300
|
+
* producer's own doc for `installLanded` says so - so the fact travels with
|
|
26301
|
+
* the report and the panel keys its heading on it.
|
|
26302
|
+
*/
|
|
26303
|
+
installLanded: input.installLanded === true
|
|
26304
|
+
}
|
|
26305
|
+
});
|
|
26306
|
+
return false;
|
|
26307
|
+
}
|
|
26308
|
+
/**
|
|
26309
|
+
* Put back the runtimes a restart displaced, and say what came back.
|
|
26310
|
+
*
|
|
26311
|
+
* Step 5 of the host tool's own order (`~/tools/lop-fleet-update`): the app
|
|
26312
|
+
* snapshots the fleet, drains it, moves the server, and then re-engages what
|
|
26313
|
+
* the move displaced - the unwatched `daemon`-kind sessions in particular,
|
|
26314
|
+
* which nothing else revives. `reengageDisplacedSessions` owns the retire wait
|
|
26315
|
+
* and the ordering rules.
|
|
26316
|
+
*
|
|
26317
|
+
* BEST EFFORT AND NEVER A BLOCKER: this runs after the server is healthy and
|
|
26318
|
+
* the update has already been reported, so a displace list that will not come
|
|
26319
|
+
* back is a logged fact rather than a failed update. That is deliberate - a
|
|
26320
|
+
* `warm` the daemon refuses must not turn a landed update into an error panel.
|
|
26321
|
+
*/
|
|
26322
|
+
async reengageFleetAfterRestart(backend, before) {
|
|
26323
|
+
try {
|
|
26324
|
+
const result = await reengageDisplacedSessions({
|
|
26325
|
+
before,
|
|
26326
|
+
readRoster: () => backend.servingSessionFleet(),
|
|
26327
|
+
engage: (row) => this.engageSessionRuntime(backend, row),
|
|
26328
|
+
sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
26329
|
+
now: () => Date.now(),
|
|
26330
|
+
graceMs: this.fleetRetireGraceMs,
|
|
26331
|
+
settleMs: this.fleetRetireSettleMs,
|
|
26332
|
+
retirePollMs: this.fleetDrainPollMs,
|
|
26333
|
+
log: (line) => logger.info(line, LogFileType.UPDATE_SERVICE)
|
|
26334
|
+
});
|
|
26335
|
+
if (result.displaced.length === 0) {
|
|
26336
|
+
if (result.stillResident.length > 0) {
|
|
26337
|
+
logger.info(
|
|
26338
|
+
`Nothing was displaced after the restart; ${result.stillResident.length} pre-swap runtime(s) were still resident when the wait ended (${result.stillResident.map((row) => row.sessionId).join(", ")}) and will go cold on their own schedule`,
|
|
26339
|
+
LogFileType.UPDATE_SERVICE
|
|
26340
|
+
);
|
|
26341
|
+
}
|
|
26342
|
+
return;
|
|
26343
|
+
}
|
|
26344
|
+
if (result.failed.length > 0) {
|
|
26345
|
+
logger.warn(
|
|
26346
|
+
`${result.failed.length} displaced session(s) did not come back after the restart: ${result.failed.map((row) => `${row.sessionId} (${row.reason})`).join(", ")}`,
|
|
26347
|
+
LogFileType.UPDATE_SERVICE
|
|
26348
|
+
);
|
|
26349
|
+
}
|
|
26350
|
+
} catch (error) {
|
|
26351
|
+
logger.warn(
|
|
26352
|
+
`Could not re-engage the sessions a restart displaced: ${error instanceof Error ? error.message : String(error)}`,
|
|
26353
|
+
LogFileType.UPDATE_SERVICE
|
|
26354
|
+
);
|
|
26355
|
+
}
|
|
26356
|
+
}
|
|
26357
|
+
/**
|
|
26358
|
+
* Start one session's runtime again, without submitting anything to it.
|
|
26359
|
+
*
|
|
26360
|
+
* THE APP'S OWN PATH FOR OPENING A SESSION (review round 2, R2-M1, which is the
|
|
26361
|
+
* finding that this call was a no-op against the shipped daemon). What this used
|
|
26362
|
+
* to do - post `sessions.watch` with a freshly generated subscription id and then
|
|
26363
|
+
* post `sessions.warm` - breaks BOTH preconditions the two routes carry:
|
|
26364
|
+
*
|
|
26365
|
+
* - **The lease must name a subscription the bridge KNOWS.** The id is minted
|
|
26366
|
+
* server-side by an events subscription (`DesktopSessionBridge.subscribe`) and
|
|
26367
|
+
* `watch` looks it up in that table, raising `KeyError` for one it has never
|
|
26368
|
+
* seen - which the route ladder answers as a **404**, one request before the
|
|
26369
|
+
* warm. A random id therefore never leased anything, and the engage reported
|
|
26370
|
+
* "did not answer" about a call it could have known would fail.
|
|
26371
|
+
* - **A warm only survives while something else holds the bridge.** The bridge is
|
|
26372
|
+
* reference-counted by IN-FLIGHT REQUESTS; a warm issued while nothing holds it
|
|
26373
|
+
* is cancelled the moment its own request returns (`routes/desktop_sessions.py`'s
|
|
26374
|
+
* `warm` docstring, pinned by
|
|
26375
|
+
* `tests/unit/server/test_desktop_sessions.py::test_a_warm_survives_its_own_request_while_a_subscriber_holds_the_bridge`).
|
|
26376
|
+
* The renderer's panel path works because a mounted `SessionPanel` holds an
|
|
26377
|
+
* EVENTS STREAM for its whole life; a one-shot warm from main held nothing, so
|
|
26378
|
+
* the 200 said the call was admitted, not that a runtime exists.
|
|
26379
|
+
*
|
|
26380
|
+
* So the engage is what CLICKING THE SESSION does, through the app's own relay:
|
|
26381
|
+
* hold the session's events stream, take the subscription id its `open` frame
|
|
26382
|
+
* carries, lease that id as a VISIBLE watch (a live visible lease is what CREATES
|
|
26383
|
+
* residency for a cold session - `DesktopSessionBridge.refresh_watch`), warm it,
|
|
26384
|
+
* and keep holding until the roster says the runtime is there.
|
|
26385
|
+
* `session-engage.ts` owns that rule and the evidence each leg rests on.
|
|
26386
|
+
*
|
|
26387
|
+
* AND THE BOOLEAN IS A CLAIM ABOUT THE MACHINE, not about a status code: every
|
|
26388
|
+
* route on this path answers 200 to a call it will not act on, so what decides the
|
|
26389
|
+
* outcome is the same roster read the fleet gate uses (`sessionHasRuntime`). The
|
|
26390
|
+
* caller's "re-engaged N of M" log line is worth exactly that much.
|
|
26391
|
+
*
|
|
26392
|
+
* `visible: true` IS ONLY SAFE WHERE A PRESENCE RECORD EXISTS. The runtime prefers
|
|
26393
|
+
* this app's machine-wide presence record and falls back to exactly this
|
|
26394
|
+
* per-connection flag (`session/runtime/server.py::_desktop_visible`), so on a
|
|
26395
|
+
* machine with no record the app is telling a session's runtime that somebody is
|
|
26396
|
+
* looking at a session nobody has open - which can suppress a banner for it
|
|
26397
|
+
* (`_visible_attach_surfaces`). It is kept because the lease-driven warm requires
|
|
26398
|
+
* a VISIBLE lease (`_lease_warm_loop`), and it is the flag the renderer's own beat
|
|
26399
|
+
* sends for a session it really is showing.
|
|
26400
|
+
*
|
|
26401
|
+
* NOT `sessions.message`, deliberately: a message would admit a turn in every
|
|
26402
|
+
* conversation this machine holds, which is work and spend the user did not ask
|
|
26403
|
+
* for, and the drain that ran before the restart is what makes a notice about
|
|
26404
|
+
* interrupted work unnecessary - by construction nothing was mid-turn when the
|
|
26405
|
+
* server moved.
|
|
26406
|
+
*/
|
|
26407
|
+
async engageSessionRuntime(backend, row) {
|
|
26408
|
+
const relay = backend.getStreamRelay();
|
|
26409
|
+
return await engageSessionThroughStream({
|
|
26410
|
+
sessionId: row.sessionId,
|
|
26411
|
+
subscribe: (sessionId2, emit) => relay.subscribe({ sessionId: sessionId2 }, (event) => {
|
|
26412
|
+
if (event.kind === "data") {
|
|
26413
|
+
emit({ kind: "data", data: event.data });
|
|
26414
|
+
return;
|
|
26415
|
+
}
|
|
26416
|
+
if (event.kind === "error") {
|
|
26417
|
+
emit({ kind: "error", detail: event.detail });
|
|
26418
|
+
return;
|
|
26419
|
+
}
|
|
26420
|
+
emit({ kind: "end" });
|
|
26421
|
+
}),
|
|
26422
|
+
unsubscribe: (streamId) => relay.unsubscribe(streamId),
|
|
26423
|
+
watch: (subscriptionId) => backend.requestDesktop({
|
|
26424
|
+
op: "sessions.watch",
|
|
26425
|
+
sessionId: row.sessionId,
|
|
26426
|
+
subscriptionId,
|
|
26427
|
+
visible: true,
|
|
26428
|
+
canNotify: false
|
|
26429
|
+
}),
|
|
26430
|
+
warm: (sessionId2) => backend.requestDesktop({ op: "sessions.warm", sessionId: sessionId2 }),
|
|
26431
|
+
hasRuntime: async () => sessionHasRuntime(await backend.servingSessionFleet(), row.sessionId),
|
|
26432
|
+
sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
26433
|
+
now: () => Date.now(),
|
|
26434
|
+
openMs: this.sessionEngageOpenMs,
|
|
26435
|
+
beatMs: this.sessionEngageBeatMs,
|
|
26436
|
+
holdMs: this.sessionEngageHoldMs,
|
|
26437
|
+
log: (line) => logger.info(line, LogFileType.UPDATE_SERVICE)
|
|
26438
|
+
}).then((outcome) => {
|
|
26439
|
+
if (!outcome.engaged) {
|
|
26440
|
+
logger.warn(
|
|
26441
|
+
`The re-engage of ${row.sessionId} did not start a runtime: ${outcome.reason ?? "no reason given"}`,
|
|
26442
|
+
LogFileType.UPDATE_SERVICE
|
|
26443
|
+
);
|
|
26444
|
+
}
|
|
26445
|
+
return outcome.engaged;
|
|
26446
|
+
});
|
|
26447
|
+
}
|
|
26448
|
+
/**
|
|
26449
|
+
* The fleet as it is now, for the before/after pair a restart is judged on.
|
|
26450
|
+
*
|
|
26451
|
+
* Null is a roster that could not be read, and every caller treats it as "no
|
|
26452
|
+
* snapshot": a re-engage with no `before` has nothing to put back, and
|
|
26453
|
+
* inventing one would re-engage sessions that were never displaced.
|
|
26454
|
+
*/
|
|
26455
|
+
async readFleetSnapshot(backend) {
|
|
26456
|
+
return backend.servingSessionFleet();
|
|
26457
|
+
}
|
|
24647
26458
|
/**
|
|
24648
26459
|
* The build the daemon serving this app BOOTED with, or null when its record
|
|
24649
26460
|
* carries no reading.
|
|
@@ -25047,6 +26858,14 @@ ${heal.removed.join("\n")}`,
|
|
|
25047
26858
|
const budgetMs = rebuildRoute ? SOURCE_REBUILD_TIMEOUT_MS : GLOBAL_UPDATE_TIMEOUT_MS;
|
|
25048
26859
|
const before = this.readInstallVersionAt(installPath) ?? plan.installedInstallVersion;
|
|
25049
26860
|
const markerBefore = rebuildRoute ? this.readRebuildMarkerState(installPath) : null;
|
|
26861
|
+
const fleetBeforeInstall = rebuildRoute ? await this.readFleetSnapshot(backend) : null;
|
|
26862
|
+
if (rebuildRoute && !await this.drainFleetForUpdate({
|
|
26863
|
+
backend,
|
|
26864
|
+
command: freshPlan.updateCommand ?? null,
|
|
26865
|
+
what: "install"
|
|
26866
|
+
})) {
|
|
26867
|
+
return false;
|
|
26868
|
+
}
|
|
25050
26869
|
this.sendToRenderer("backend-update-progress", {
|
|
25051
26870
|
phase: "installing",
|
|
25052
26871
|
sourceRebuild: rebuildRoute
|
|
@@ -25139,7 +26958,9 @@ ${heal.removed.join("\n")}`,
|
|
|
25139
26958
|
`Global install updated: ${before ?? "unknown"} -> ${after ?? "unknown"}`,
|
|
25140
26959
|
LogFileType.UPDATE_SERVICE
|
|
25141
26960
|
);
|
|
25142
|
-
|
|
26961
|
+
const generationInstall = freshPlan.managedRoute === "entry-point";
|
|
26962
|
+
const installAlreadyCurrent = !rebuildRoute && before !== null && after !== null && before === after;
|
|
26963
|
+
if (backend.isUsingExternalBackend() || generationInstall && !installAlreadyCurrent) {
|
|
25143
26964
|
const health = await this.getInstalledBackendVersion();
|
|
25144
26965
|
const running2 = this.eventRunningVersion(health);
|
|
25145
26966
|
logger.info(
|
|
@@ -25160,14 +26981,30 @@ ${heal.removed.join("\n")}`,
|
|
|
25160
26981
|
});
|
|
25161
26982
|
return true;
|
|
25162
26983
|
}
|
|
26984
|
+
if (!await this.drainFleetForUpdate({
|
|
26985
|
+
backend,
|
|
26986
|
+
command: freshPlan.updateCommand ?? null,
|
|
26987
|
+
what: "restart",
|
|
26988
|
+
/*
|
|
26989
|
+
* A press with nothing left to install has no install to report as
|
|
26990
|
+
* landed, and saying one was would be the one false thing in the
|
|
26991
|
+
* refusal.
|
|
26992
|
+
*/
|
|
26993
|
+
installLanded: !installAlreadyCurrent
|
|
26994
|
+
})) {
|
|
26995
|
+
logger.info(
|
|
26996
|
+
installAlreadyCurrent ? "Nothing was left to install; the restart was refused because the fleet did not drain. The daemon keeps serving the build it loaded." : "The install landed; the restart was refused because the fleet did not drain. The daemon keeps serving the build it loaded.",
|
|
26997
|
+
LogFileType.UPDATE_SERVICE
|
|
26998
|
+
);
|
|
26999
|
+
return false;
|
|
27000
|
+
}
|
|
25163
27001
|
logger.info(
|
|
25164
27002
|
"Restarting backend service onto the updated install...",
|
|
25165
27003
|
LogFileType.UPDATE_SERVICE
|
|
25166
27004
|
);
|
|
25167
27005
|
this.sendToRenderer("backend-update-progress", { phase: "restarting" });
|
|
25168
|
-
|
|
27006
|
+
const fleetBeforeRestart = await this.readFleetSnapshot(backend);
|
|
25169
27007
|
const restartSuccess = await backend.restart();
|
|
25170
|
-
backend.setAutoUpdating(false);
|
|
25171
27008
|
if (!restartSuccess) {
|
|
25172
27009
|
logger.error(
|
|
25173
27010
|
"Backend service restart failed after the global install update",
|
|
@@ -25220,6 +27057,13 @@ ${heal.removed.join("\n")}`,
|
|
|
25220
27057
|
`Backend reports version ${reported} after the global install update`,
|
|
25221
27058
|
LogFileType.UPDATE_SERVICE
|
|
25222
27059
|
);
|
|
27060
|
+
const reengageSnapshot = unionFleetSnapshots(
|
|
27061
|
+
fleetBeforeInstall,
|
|
27062
|
+
fleetBeforeRestart
|
|
27063
|
+
);
|
|
27064
|
+
if (reengageSnapshot !== null) {
|
|
27065
|
+
await this.reengageFleetAfterRestart(backend, reengageSnapshot);
|
|
27066
|
+
}
|
|
25223
27067
|
this.sendToRenderer("backend-update-completed", {
|
|
25224
27068
|
installVersion: after,
|
|
25225
27069
|
runningVersion: reported,
|
|
@@ -25302,6 +27146,12 @@ ${heal.removed.join("\n")}`,
|
|
|
25302
27146
|
}
|
|
25303
27147
|
async updateBackend(targetVersion) {
|
|
25304
27148
|
logger.info("Updating backend...", LogFileType.UPDATE_SERVICE);
|
|
27149
|
+
const backend = this.backendService;
|
|
27150
|
+
const heldElsewhere = backend?.checkIsAutoUpdating?.() === true;
|
|
27151
|
+
if (!heldElsewhere) {
|
|
27152
|
+
this.fleetDrainSpentMs = 0;
|
|
27153
|
+
backend?.setAutoUpdating(true);
|
|
27154
|
+
}
|
|
25305
27155
|
try {
|
|
25306
27156
|
if (!this.backendService) {
|
|
25307
27157
|
logger.error(
|
|
@@ -25421,6 +27271,8 @@ ${heal.removed.join("\n")}`,
|
|
|
25421
27271
|
});
|
|
25422
27272
|
}
|
|
25423
27273
|
return false;
|
|
27274
|
+
} finally {
|
|
27275
|
+
if (!heldElsewhere) backend?.setAutoUpdating(false);
|
|
25424
27276
|
}
|
|
25425
27277
|
}
|
|
25426
27278
|
/**
|
|
@@ -25563,6 +27415,15 @@ ${reason}` : ""}`,
|
|
|
25563
27415
|
});
|
|
25564
27416
|
return true;
|
|
25565
27417
|
}
|
|
27418
|
+
const fleetAfterPublish = await this.readFleetSnapshot(backend);
|
|
27419
|
+
if (!await this.drainFleetForUpdate({
|
|
27420
|
+
backend,
|
|
27421
|
+
command: null,
|
|
27422
|
+
what: "restart",
|
|
27423
|
+
installLanded: outcome.replaced
|
|
27424
|
+
})) {
|
|
27425
|
+
return false;
|
|
27426
|
+
}
|
|
25566
27427
|
logger.info(
|
|
25567
27428
|
"Restarting backend service onto the published environment...",
|
|
25568
27429
|
LogFileType.UPDATE_SERVICE
|
|
@@ -25570,9 +27431,8 @@ ${reason}` : ""}`,
|
|
|
25570
27431
|
if (willPublish) {
|
|
25571
27432
|
this.sendToRenderer("backend-update-progress", { phase: "restarting" });
|
|
25572
27433
|
}
|
|
25573
|
-
|
|
27434
|
+
const fleetBeforeRestart = await this.readFleetSnapshot(backend);
|
|
25574
27435
|
const restarted = await backend.restart();
|
|
25575
|
-
backend.setAutoUpdating(false);
|
|
25576
27436
|
const healthy = restarted ? await this.checkBackendHealth() : false;
|
|
25577
27437
|
if (!restarted || !healthy) {
|
|
25578
27438
|
logger.error(
|
|
@@ -25648,6 +27508,13 @@ ${reason}` : ""}`,
|
|
|
25648
27508
|
`The server serving this app reports ${running} after the app-managed environment update`,
|
|
25649
27509
|
LogFileType.UPDATE_SERVICE
|
|
25650
27510
|
);
|
|
27511
|
+
const reengageSnapshot = unionFleetSnapshots(
|
|
27512
|
+
fleetAfterPublish,
|
|
27513
|
+
fleetBeforeRestart
|
|
27514
|
+
);
|
|
27515
|
+
if (reengageSnapshot !== null) {
|
|
27516
|
+
await this.reengageFleetAfterRestart(backend, reengageSnapshot);
|
|
27517
|
+
}
|
|
25651
27518
|
this.sendToRenderer("backend-update-completed", {
|
|
25652
27519
|
installVersion,
|
|
25653
27520
|
runningVersion: running,
|