local-operator-ui 0.28.1 → 0.28.3
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 +613 -63
- package/out/renderer/assets/{_basePickBy-C02O_f_R.js → _basePickBy-BBP0F16i.js} +1 -1
- package/out/renderer/assets/{_baseUniq-CyWXoXbG.js → _baseUniq-DjxRRM-q.js} +1 -1
- package/out/renderer/assets/{agent-details-page-0du1YTI_.js → agent-details-page-b4Jm7Fqp.js} +1 -1
- package/out/renderer/assets/{agent-hub-page-B7nVi1Jy.js → agent-hub-page-nCGrd10n.js} +1 -1
- package/out/renderer/assets/{agents-page-CYSotfFo.js → agents-page-DLjX0IMj.js} +2 -2
- package/out/renderer/assets/{arc-XTNYV0i5.js → arc-C2DRbjeQ.js} +1 -1
- package/out/renderer/assets/{architectureDiagram-IEHRJDOE-DIL6Ll1r.js → architectureDiagram-IEHRJDOE-Bu3QEQkj.js} +1 -1
- package/out/renderer/assets/{blockDiagram-JOT3LUYC-Ep_iozU3.js → blockDiagram-JOT3LUYC-B4dcpLda.js} +1 -1
- package/out/renderer/assets/browser-page-XjS6AJWX.js +1 -0
- package/out/renderer/assets/{c4Diagram-VJAJSXHY-BjTSpvzv.js → c4Diagram-VJAJSXHY-CM93Qt3c.js} +1 -1
- package/out/renderer/assets/channel-BhgbMo9c.js +1 -0
- package/out/renderer/assets/{chunk-4BMEZGHF-Bvj-NRkw.js → chunk-4BMEZGHF-BoCakpR9.js} +1 -1
- package/out/renderer/assets/{chunk-A2AXSNBT-UUmKE301.js → chunk-A2AXSNBT-fPE0V_zK.js} +1 -1
- package/out/renderer/assets/{chunk-AEK57VVT-NW7D31W_.js → chunk-AEK57VVT-PeU0U0Jr.js} +1 -1
- package/out/renderer/assets/{chunk-D6G4REZN-DNqq3eoh.js → chunk-D6G4REZN-dqXsUJuN.js} +1 -1
- package/out/renderer/assets/{chunk-RZ5BOZE2-Bga-d9GJ.js → chunk-RZ5BOZE2-iOEMmjI6.js} +1 -1
- package/out/renderer/assets/{chunk-XZIHB7SX-BHx3SJQz.js → chunk-XZIHB7SX-DNMxuK8c.js} +1 -1
- package/out/renderer/assets/classDiagram-GIVACNV2-BFMzYHo3.js +1 -0
- package/out/renderer/assets/classDiagram-v2-COTLJTTW-BFMzYHo3.js +1 -0
- package/out/renderer/assets/clone-AF0jx5V9.js +1 -0
- package/out/renderer/assets/{compact-pagination-DNVRJTAX.js → compact-pagination-CdlSy_zE.js} +1 -1
- package/out/renderer/assets/{dagre-OKDRZEBW-KnrtYuYl.js → dagre-OKDRZEBW-CLEK8caE.js} +1 -1
- package/out/renderer/assets/{diagram-SSKATNLV-VAbMy5h_.js → diagram-SSKATNLV-Yql0QmJX.js} +1 -1
- package/out/renderer/assets/{diagram-VNBRO52H-i8oVPPp5.js → diagram-VNBRO52H-o_VgZJcg.js} +1 -1
- package/out/renderer/assets/{erDiagram-Q7BY3M3F-Bkg4Xj6A.js → erDiagram-Q7BY3M3F-COJrT7PC.js} +1 -1
- package/out/renderer/assets/{flowDiagram-4HSFHLVR-D6YF1gP8.js → flowDiagram-4HSFHLVR-DoxahilY.js} +1 -1
- package/out/renderer/assets/{ganttDiagram-APWFNJXF-CV6sClgt.js → ganttDiagram-APWFNJXF-Bm7z-0v8.js} +1 -1
- package/out/renderer/assets/{gitGraphDiagram-7IBYFJ6S-OIj63WSM.js → gitGraphDiagram-7IBYFJ6S-CpwCBQRX.js} +1 -1
- package/out/renderer/assets/{graph-BdxNQDYo.js → graph-DR0IUFTR.js} +1 -1
- package/out/renderer/assets/icon-B0HMKFlq.css +1 -0
- package/out/renderer/assets/{index-5no0GDO0.js → index-DnW_kgMc.js} +382 -377
- package/out/renderer/assets/{index-B0qhIlIL.js → index-KI186JLX.js} +1 -1
- package/out/renderer/assets/{index-Ct9PNAEI.js → index-NgjUzrqT.js} +1 -1
- package/out/renderer/assets/{infoDiagram-PH2N3AL5-D8oE75Dn.js → infoDiagram-PH2N3AL5-D2IPdCqr.js} +1 -1
- package/out/renderer/assets/{installer-tV2_PPwX.js → installer-DYouFhce.js} +1 -1
- package/out/renderer/assets/{journeyDiagram-U35MCT3I-X-cfXqeY.js → journeyDiagram-U35MCT3I-kZ8pfyqw.js} +1 -1
- package/out/renderer/assets/{kanban-definition-NDS4AKOZ-DKBkspnj.js → kanban-definition-NDS4AKOZ-vULF1Xsx.js} +1 -1
- package/out/renderer/assets/{layout-COR8ZIio.js → layout-BTG-f-9Y.js} +1 -1
- package/out/renderer/assets/{legacy-agents-page-DWYOf5hQ.js → legacy-agents-page-F6UxsNw3.js} +1 -1
- package/out/renderer/assets/{mermaid.core-D-giE_vB.js → mermaid.core-C9ejm8if.js} +5 -5
- package/out/renderer/assets/{mindmap-definition-ALO5MXBD-BH0jLzqf.js → mindmap-definition-ALO5MXBD-DoPXZY6O.js} +1 -1
- package/out/renderer/assets/{page-header-BhuuVNah.js → page-header-ByGWC-Bb.js} +1 -1
- package/out/renderer/assets/{parseISO-DL8GBZII.js → parseISO-DEXiCU5D.js} +1 -1
- package/out/renderer/assets/{pieDiagram-IB7DONF6-DW9M-Vtv.js → pieDiagram-IB7DONF6-DFOQu0lV.js} +1 -1
- package/out/renderer/assets/{quadrantDiagram-7GDLP6J5-CcbQfuRN.js → quadrantDiagram-7GDLP6J5-5HNeqaT-.js} +1 -1
- package/out/renderer/assets/{radar-MK3ICKWK-Cb8VhXQ6.js → radar-MK3ICKWK-B4kS90ER.js} +1 -1
- package/out/renderer/assets/{radient-auth-buttons-W3Ue6kiR.js → radient-auth-buttons-D5aeH47J.js} +1 -1
- package/out/renderer/assets/{requirementDiagram-KVF5MWMF-CSZX2Wo5.js → requirementDiagram-KVF5MWMF-BccyNVCD.js} +1 -1
- package/out/renderer/assets/{sankeyDiagram-QLVOVGJD-DOLbRwKf.js → sankeyDiagram-QLVOVGJD-CQQdfrb1.js} +1 -1
- package/out/renderer/assets/{schedules-page-BV8OqZDh.js → schedules-page-BVR4d6SM.js} +1 -1
- package/out/renderer/assets/{sequenceDiagram-X6HHIX6F-DI4gtm_m.js → sequenceDiagram-X6HHIX6F-BqHg5YQ3.js} +1 -1
- package/out/renderer/assets/{settings-page-Bh-7VIcH.js → settings-page-DjY4p8xo.js} +1 -1
- package/out/renderer/assets/{stateDiagram-DGXRK772-Dn0HYlb8.js → stateDiagram-DGXRK772-b7i6Bt1H.js} +1 -1
- package/out/renderer/assets/stateDiagram-v2-YXO3MK2T-D7Rngykw.js +1 -0
- package/out/renderer/assets/{timeline-definition-BDJGKUSR-BmVa1VwX.js → timeline-definition-BDJGKUSR-B5v_2PQx.js} +1 -1
- package/out/renderer/assets/{use-agent-like-mutation-W2AIG7k0.js → use-agent-like-mutation-C6zdtkZo.js} +1 -1
- package/out/renderer/assets/{xychartDiagram-VJFVF3MP-BRl6kaP4.js → xychartDiagram-VJFVF3MP-Djcwglte.js} +1 -1
- package/out/renderer/index.html +3 -3
- package/out/renderer/installer.html +3 -3
- package/package.json +2 -2
- package/out/renderer/assets/browser-page-DwWfYMRL.js +0 -1
- package/out/renderer/assets/channel-D9JdgjaE.js +0 -1
- package/out/renderer/assets/classDiagram-GIVACNV2--M4DfyDu.js +0 -1
- package/out/renderer/assets/classDiagram-v2-COTLJTTW--M4DfyDu.js +0 -1
- package/out/renderer/assets/clone-ApORSESi.js +0 -1
- package/out/renderer/assets/icon-DqiwmYFW.css +0 -1
- package/out/renderer/assets/stateDiagram-v2-YXO3MK2T-B1ZGpxnX.js +0 -1
- /package/out/renderer/assets/{icon-DucQIiEv.js → icon-DSd8jC90.js} +0 -0
package/out/main/index.js
CHANGED
|
@@ -393,6 +393,38 @@ const desktopRequestSchema = zod.z.discriminatedUnion("op", [
|
|
|
393
393
|
sessionId,
|
|
394
394
|
completionToken: zod.z.string().uuid()
|
|
395
395
|
}).strict(),
|
|
396
|
+
/*
|
|
397
|
+
* Bulk acknowledgement: mark many completions read in ONE user gesture
|
|
398
|
+
* (`POST /v1/desktop/attention/seen`).
|
|
399
|
+
*
|
|
400
|
+
* Its OWN op rather than a loop over `sessions.seen`, for two reasons that are
|
|
401
|
+
* both about what the receipt MEANS. The backend writes the whole batch in one
|
|
402
|
+
* `BEGIN IMMEDIATE`, so an observer sees the pre-batch or the post-batch receipt
|
|
403
|
+
* set and never a prefix; N round trips would expose a partial batch to every
|
|
404
|
+
* poller, and a failure half way would leave the user unable to tell which
|
|
405
|
+
* marks went. And the items are TOKEN-bound on purpose: the batch names exactly
|
|
406
|
+
* the completions the client has RENDERED, so a completion published between
|
|
407
|
+
* the render and the click is not in it and stays unread. A watermark sweep
|
|
408
|
+
* ("acknowledge everything now") would clear exactly that result instead, which
|
|
409
|
+
* is the hazard the per-session receipt exists to prevent — so this op never
|
|
410
|
+
* carries a time, a count or a "all" flag, only the pairs the client observed.
|
|
411
|
+
*
|
|
412
|
+
* `items` is 1..500: 500 is the catalogue's own maximum page, so every row the
|
|
413
|
+
* sidebar can hold is sendable in one call and `markAllRead` never has to
|
|
414
|
+
* chunk (chunking would break "one gesture, one request").
|
|
415
|
+
*/
|
|
416
|
+
zod.z.object({
|
|
417
|
+
op: zod.z.literal("attention.seen"),
|
|
418
|
+
items: zod.z.array(
|
|
419
|
+
zod.z.object({
|
|
420
|
+
sessionId,
|
|
421
|
+
// A REAL uuid, like `sessions.seen`: a token that is not one cannot
|
|
422
|
+
// name a completion, so it is refused here rather than round-tripped
|
|
423
|
+
// to answer `unknown` for an item the client should not have sent.
|
|
424
|
+
completionToken: zod.z.string().uuid()
|
|
425
|
+
}).strict()
|
|
426
|
+
).min(1).max(500)
|
|
427
|
+
}).strict(),
|
|
396
428
|
// Cross-surface delivery claim, NOT a read receipt. `claim_delivery`
|
|
397
429
|
// serialises the observers that can see one completion (a TUI, this app) so
|
|
398
430
|
// exactly one of them toasts it. It deliberately never advances the read
|
|
@@ -992,6 +1024,7 @@ function desktopRequestDeadlineMs(op) {
|
|
|
992
1024
|
return LONG_READ_OPS.has(op) ? DESKTOP_LONG_READ_DEADLINE_MS : DESKTOP_CONTROL_DEADLINE_MS;
|
|
993
1025
|
}
|
|
994
1026
|
const DESKTOP_DEADLINE_EXCEEDED_CODE = "deadline_exceeded";
|
|
1027
|
+
const DESKTOP_FOREGROUND_REQUIRED_MESSAGE = "View these completions in the foreground before marking them read.";
|
|
995
1028
|
const READ_ONLY_OPS = /* @__PURE__ */ new Set([
|
|
996
1029
|
"capabilities",
|
|
997
1030
|
"accounts.list",
|
|
@@ -1234,6 +1267,25 @@ function desktopEndpoint(request) {
|
|
|
1234
1267
|
method: "POST",
|
|
1235
1268
|
body: { completion_token: request.completionToken }
|
|
1236
1269
|
};
|
|
1270
|
+
case "attention.seen":
|
|
1271
|
+
return {
|
|
1272
|
+
/*
|
|
1273
|
+
* A path with no `{session_id}` in it, deliberately: the batch spans
|
|
1274
|
+
* sessions, and `/v1/desktop/attention/seen` cannot be shadowed by the
|
|
1275
|
+
* per-session route at any registration order. The body is snake_case
|
|
1276
|
+
* like every other route in that module, and the conversation identity
|
|
1277
|
+
* is NEVER taken from the client — the backend derives `session/<id>`
|
|
1278
|
+
* from the validated session id, the rule `/seen` already follows.
|
|
1279
|
+
*/
|
|
1280
|
+
path: "/v1/desktop/attention/seen",
|
|
1281
|
+
method: "POST",
|
|
1282
|
+
body: {
|
|
1283
|
+
items: request.items.map((item) => ({
|
|
1284
|
+
session_id: item.sessionId,
|
|
1285
|
+
completion_token: item.completionToken
|
|
1286
|
+
}))
|
|
1287
|
+
}
|
|
1288
|
+
};
|
|
1237
1289
|
case "sessions.notified":
|
|
1238
1290
|
return {
|
|
1239
1291
|
path: `/v1/desktop/sessions/${request.sessionId}/notified`,
|
|
@@ -2657,7 +2709,20 @@ function parsePendingServerUpdateMarker(raw) {
|
|
|
2657
2709
|
return {
|
|
2658
2710
|
before: typeof parsed.before === "string" ? parsed.before : null,
|
|
2659
2711
|
target: typeof parsed.target === "string" ? parsed.target : null,
|
|
2660
|
-
startedAt: parsed.startedAt
|
|
2712
|
+
startedAt: parsed.startedAt,
|
|
2713
|
+
// The three identity fields are additive: a record written before they
|
|
2714
|
+
// existed parses as "no evidence", which is exactly what the evaluator
|
|
2715
|
+
// bounds by the deadline rather than by a pid.
|
|
2716
|
+
deadlineAt: typeof parsed.deadlineAt === "string" ? parsed.deadlineAt : null,
|
|
2717
|
+
groupPid: typeof parsed.groupPid === "number" ? parsed.groupPid : null,
|
|
2718
|
+
groupStartedAt: typeof parsed.groupStartedAt === "string" ? parsed.groupStartedAt : null,
|
|
2719
|
+
/*
|
|
2720
|
+
* The install the attempt ran, and the reason it is parsed rather than dropped:
|
|
2721
|
+
* the reader has no press in scope, so the record is the ONLY thing that can tell
|
|
2722
|
+
* it which tree to ask about. A record without it is an older build's, and the
|
|
2723
|
+
* reader says so and falls back rather than guessing silently.
|
|
2724
|
+
*/
|
|
2725
|
+
installPath: typeof parsed.installPath === "string" ? parsed.installPath : null
|
|
2661
2726
|
};
|
|
2662
2727
|
} catch {
|
|
2663
2728
|
return null;
|
|
@@ -2678,6 +2743,29 @@ function clearPendingServerUpdateMarker(dir) {
|
|
|
2678
2743
|
fs.rmSync(path2, { force: true });
|
|
2679
2744
|
return true;
|
|
2680
2745
|
}
|
|
2746
|
+
const PENDING_SERVER_UPDATE_GRACE_MS = 10 * 6e4;
|
|
2747
|
+
function evaluatePendingServerUpdateMarker(input) {
|
|
2748
|
+
const marker = input.marker;
|
|
2749
|
+
if (!marker) return { kind: "none" };
|
|
2750
|
+
if (marker.groupPid === null) {
|
|
2751
|
+
return { kind: "expired", marker, reason: "unproven" };
|
|
2752
|
+
}
|
|
2753
|
+
if (!input.groupAlive) {
|
|
2754
|
+
return { kind: "expired", marker, reason: "gone" };
|
|
2755
|
+
}
|
|
2756
|
+
const recorded = marker.groupStartedAt;
|
|
2757
|
+
const live = input.liveGroupStamp ?? null;
|
|
2758
|
+
if (recorded && live) {
|
|
2759
|
+
return recorded === live ? { kind: "running", marker, reason: "owned" } : { kind: "expired", marker, reason: "foreign-pid" };
|
|
2760
|
+
}
|
|
2761
|
+
const deadline2 = Date.parse(marker.deadlineAt ?? "");
|
|
2762
|
+
const now = input.now ?? Date.now();
|
|
2763
|
+
const grace = input.graceMs ?? PENDING_SERVER_UPDATE_GRACE_MS;
|
|
2764
|
+
if (Number.isFinite(deadline2) && now > deadline2 + grace) {
|
|
2765
|
+
return { kind: "expired", marker, reason: "expired" };
|
|
2766
|
+
}
|
|
2767
|
+
return { kind: "running", marker, reason: "unproven" };
|
|
2768
|
+
}
|
|
2681
2769
|
const LAST_INSTALL_ATTEMPT_FILE = "last-update-install.json";
|
|
2682
2770
|
function lastInstallAttemptPath(dir) {
|
|
2683
2771
|
return path$1.join(dir, LAST_INSTALL_ATTEMPT_FILE);
|
|
@@ -3277,6 +3365,45 @@ function readSourceRef(prefix) {
|
|
|
3277
3365
|
return null;
|
|
3278
3366
|
}
|
|
3279
3367
|
}
|
|
3368
|
+
function readSourceMarkerState(prefix) {
|
|
3369
|
+
if (!prefix) return { ref: null, mtimeMs: null };
|
|
3370
|
+
const marker = path$1.join(prefix, ".lop-source");
|
|
3371
|
+
try {
|
|
3372
|
+
const stat = fs.statSync(marker);
|
|
3373
|
+
return { ref: readSourceRef(prefix), mtimeMs: stat.mtimeMs };
|
|
3374
|
+
} catch {
|
|
3375
|
+
return { ref: null, mtimeMs: null };
|
|
3376
|
+
}
|
|
3377
|
+
}
|
|
3378
|
+
function didSourceRebuildLand(input) {
|
|
3379
|
+
const { before, after } = input;
|
|
3380
|
+
const hadMarker = before.ref !== null || before.mtimeMs !== null;
|
|
3381
|
+
const hasMarker = after.ref !== null || after.mtimeMs !== null;
|
|
3382
|
+
if (!hasMarker) return false;
|
|
3383
|
+
if (!hadMarker) return true;
|
|
3384
|
+
if (before.ref !== null && after.ref !== null) {
|
|
3385
|
+
return before.ref !== after.ref;
|
|
3386
|
+
}
|
|
3387
|
+
if (before.mtimeMs !== null && after.mtimeMs !== null) {
|
|
3388
|
+
return after.mtimeMs > before.mtimeMs;
|
|
3389
|
+
}
|
|
3390
|
+
return false;
|
|
3391
|
+
}
|
|
3392
|
+
const GUARD_BYPASS_ADVICE = /--skip-[a-z-]+|--allow-downgrade|--force\b|--no-verify|--ignore-checks/i;
|
|
3393
|
+
const TERMINAL_INSTRUCTION = /^(?!\S+:\s)(?:git|lop-update|uv|pip|pipx|python\d?|sudo)\b/i;
|
|
3394
|
+
function installDiagnosisLines(text, maxLines = 6) {
|
|
3395
|
+
const lines = text.split("\n").map((line) => line.trim()).filter((line) => line.length > 0);
|
|
3396
|
+
if (lines.length === 0) return "";
|
|
3397
|
+
const allowed = lines.filter((line) => !GUARD_BYPASS_ADVICE.test(line));
|
|
3398
|
+
if (allowed.length === 0) return "";
|
|
3399
|
+
const head = [];
|
|
3400
|
+
for (const line of allowed) {
|
|
3401
|
+
if (head.length >= maxLines) break;
|
|
3402
|
+
if (head.length > 0 && TERMINAL_INSTRUCTION.test(line)) break;
|
|
3403
|
+
head.push(line);
|
|
3404
|
+
}
|
|
3405
|
+
return head.join("\n").slice(0, 800);
|
|
3406
|
+
}
|
|
3280
3407
|
const LOCAL_OPERATOR_DIST_INFO_VERSION = /^local[-_]operator[-_](.+)\.dist-info$/;
|
|
3281
3408
|
function newerDistInfoVersion(candidate, current) {
|
|
3282
3409
|
if (current === null) return true;
|
|
@@ -3501,8 +3628,14 @@ function classifyGlobalInstall(identity) {
|
|
|
3501
3628
|
if (identity.installer === "pip") return "pip";
|
|
3502
3629
|
return "global-unknown";
|
|
3503
3630
|
}
|
|
3631
|
+
function installPrefixOf(identity) {
|
|
3632
|
+
if (identity.venvPrefix) return identity.venvPrefix;
|
|
3633
|
+
const script = identity.realPath ?? identity.path;
|
|
3634
|
+
return script ? path$1.dirname(path$1.dirname(script)) : null;
|
|
3635
|
+
}
|
|
3504
3636
|
function resolveGlobalInstallPlan(input) {
|
|
3505
3637
|
const kind = classifyGlobalInstall(input.identity);
|
|
3638
|
+
const installPrefix = installPrefixOf(input.identity);
|
|
3506
3639
|
const sourceBuild = kind === "editable" || kind === "uv-tool" && isSourceBuildRef(input.identity.sourceRef);
|
|
3507
3640
|
const detail = input.identity.path ? `local-operator resolves to ${input.identity.path}${input.identity.realPath && input.identity.realPath !== input.identity.path ? ` (${input.identity.realPath})` : ""}, classified as ${kind}.${kind === "editable" ? " Built from source on this machine, so it follows the checkout rather than the published release." : ""}` : "local-operator was not found on PATH.";
|
|
3508
3641
|
if (kind === "editable") {
|
|
@@ -3511,15 +3644,18 @@ function resolveGlobalInstallPlan(input) {
|
|
|
3511
3644
|
updateCommand: "",
|
|
3512
3645
|
remedy: "The server is running from a source checkout on this machine rather than an installed copy, so update it with lop-update after your change is merged.",
|
|
3513
3646
|
detail,
|
|
3514
|
-
sourceBuild: true
|
|
3647
|
+
sourceBuild: true,
|
|
3648
|
+
managedRoute: null,
|
|
3649
|
+
installPrefix
|
|
3515
3650
|
};
|
|
3516
3651
|
}
|
|
3517
3652
|
if (kind === "uv-tool") {
|
|
3518
3653
|
const managed = generationInstallRoot(input.identity) !== null;
|
|
3519
|
-
const
|
|
3654
|
+
const sourceRebuildRoute = !managed && sourceBuild && (input.sourceRebuild ?? null) !== null;
|
|
3655
|
+
const provenance = sourceBuild ? " source build of this machine's checkout; `lop update` would install the published release over it." : "";
|
|
3520
3656
|
return {
|
|
3521
|
-
canManageUpdate: managed,
|
|
3522
|
-
updateCommand: "lop update",
|
|
3657
|
+
canManageUpdate: managed || sourceRebuildRoute,
|
|
3658
|
+
updateCommand: sourceRebuildRoute ? "lop-update" : "lop update",
|
|
3523
3659
|
/*
|
|
3524
3660
|
* The remedy is the sentence above the command well in the by-hand panel
|
|
3525
3661
|
* and the consequence line in the managed offer, and each arm says the
|
|
@@ -3532,6 +3668,10 @@ function resolveGlobalInstallPlan(input) {
|
|
|
3532
3668
|
* destructive thing this path does, so it is stated here rather than in
|
|
3533
3669
|
* the in-flight panel the user reaches only after pressing.
|
|
3534
3670
|
*
|
|
3671
|
+
* Source build - the same disclosure with the one difference that matters:
|
|
3672
|
+
* what is rebuilt is THEIR checkout, and the version they end up on is the
|
|
3673
|
+
* checkout's rather than the release the app offered.
|
|
3674
|
+
*
|
|
3535
3675
|
* Legacy - WHY the app will not press the button, which is what the user
|
|
3536
3676
|
* is choosing between (reviews U8, N1). It lived only in the mono Details
|
|
3537
3677
|
* line, trailing a resolved path and a classification.
|
|
@@ -3540,9 +3680,11 @@ function resolveGlobalInstallPlan(input) {
|
|
|
3540
3680
|
* panels, and in the by-hand panel the next line was the version sentence,
|
|
3541
3681
|
* so the promise landed on the wrong line (review D5).
|
|
3542
3682
|
*/
|
|
3543
|
-
remedy: managed ? "The app updates this install and then restarts the server it started, so a turn that is in flight is dropped while the server comes back. This can take a minute or two." : "This install predates the non-disruptive installer, so update it once from your terminal. This install's updater rewrites the shared environment in place, which can interrupt sessions mid-turn; the app manages updates after that.",
|
|
3544
|
-
detail: `${detail}${provenance}`,
|
|
3545
|
-
sourceBuild
|
|
3683
|
+
remedy: managed ? "The app updates this install and then restarts the server it started, so a turn that is in flight is dropped while the server comes back. This can take a minute or two." : sourceRebuildRoute ? "Rebuilds this checkout with `lop-update`. The rebuild happens in place, so sessions on this machine can be interrupted while it runs, 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. This install's updater rewrites the shared environment in place, which can interrupt sessions mid-turn; the app manages updates after that.",
|
|
3684
|
+
detail: `${detail}${managed ? provenance : sourceRebuildRoute ? " source build of this machine's checkout; an in-place rebuild." : provenance}`,
|
|
3685
|
+
sourceBuild,
|
|
3686
|
+
managedRoute: managed ? "entry-point" : sourceRebuildRoute ? "source-build" : null,
|
|
3687
|
+
installPrefix
|
|
3546
3688
|
};
|
|
3547
3689
|
}
|
|
3548
3690
|
if (kind === "pipx") {
|
|
@@ -3551,7 +3693,9 @@ function resolveGlobalInstallPlan(input) {
|
|
|
3551
3693
|
updateCommand: "pipx upgrade local-operator",
|
|
3552
3694
|
remedy: "The server is a pipx install, so update it from your terminal",
|
|
3553
3695
|
detail,
|
|
3554
|
-
sourceBuild: false
|
|
3696
|
+
sourceBuild: false,
|
|
3697
|
+
managedRoute: null,
|
|
3698
|
+
installPrefix
|
|
3555
3699
|
};
|
|
3556
3700
|
}
|
|
3557
3701
|
if (kind === "pip") {
|
|
@@ -3560,7 +3704,9 @@ function resolveGlobalInstallPlan(input) {
|
|
|
3560
3704
|
updateCommand: "pip install --upgrade local-operator",
|
|
3561
3705
|
remedy: "The server is a pip install, so update it from your terminal",
|
|
3562
3706
|
detail,
|
|
3563
|
-
sourceBuild: false
|
|
3707
|
+
sourceBuild: false,
|
|
3708
|
+
managedRoute: null,
|
|
3709
|
+
installPrefix
|
|
3564
3710
|
};
|
|
3565
3711
|
}
|
|
3566
3712
|
return {
|
|
@@ -3568,7 +3714,9 @@ function resolveGlobalInstallPlan(input) {
|
|
|
3568
3714
|
updateCommand: "",
|
|
3569
3715
|
remedy: "The app could not tell how this server was installed, so update it with the tool you installed it with - uv, pipx or pip",
|
|
3570
3716
|
detail,
|
|
3571
|
-
sourceBuild: false
|
|
3717
|
+
sourceBuild: false,
|
|
3718
|
+
managedRoute: null,
|
|
3719
|
+
installPrefix
|
|
3572
3720
|
};
|
|
3573
3721
|
}
|
|
3574
3722
|
const PINNABLE_VERSION_REGEX = /^(?:\d+!)?\d+(?:\.\d+)*(?:(?:a|b|rc)\d+)?(?:\.post\d+)?(?:\.dev\d+)?(?:[-+][0-9A-Za-z.]+)?$/;
|
|
@@ -14982,16 +15130,16 @@ function createSessionCookieQuitHold(deps) {
|
|
|
14982
15130
|
};
|
|
14983
15131
|
}
|
|
14984
15132
|
const OPERATION_ID = /^[a-zA-Z0-9_-]{1,128}$/;
|
|
15133
|
+
const FOREGROUND_RECEIPT_OPS = /* @__PURE__ */ new Set([
|
|
15134
|
+
"sessions.seen",
|
|
15135
|
+
"attention.seen"
|
|
15136
|
+
]);
|
|
14985
15137
|
function guardForegroundReceipts(window, send2) {
|
|
14986
15138
|
return (input) => {
|
|
14987
|
-
if (input !== null && typeof input === "object" && "op" in input && input.op === "
|
|
15139
|
+
if (input !== null && typeof input === "object" && "op" in input && typeof input.op === "string" && FOREGROUND_RECEIPT_OPS.has(input.op)) {
|
|
14988
15140
|
const owner = window();
|
|
14989
15141
|
if (!owner || owner.isDestroyed() || !owner.isVisible() || owner.isMinimized() || !owner.isFocused()) {
|
|
14990
|
-
return Promise.reject(
|
|
14991
|
-
new Error(
|
|
14992
|
-
"View this completion in the foreground before marking it read."
|
|
14993
|
-
)
|
|
14994
|
-
);
|
|
15142
|
+
return Promise.reject(new Error(DESKTOP_FOREGROUND_REQUIRED_MESSAGE));
|
|
14995
15143
|
}
|
|
14996
15144
|
}
|
|
14997
15145
|
return send2(input);
|
|
@@ -16554,9 +16702,176 @@ function clauseTextOf(message, start, end) {
|
|
|
16554
16702
|
function stripErrorPrefixes(message) {
|
|
16555
16703
|
return message.replace(LEADING_ERROR_PREFIXES, "").replace(EMBEDDED_ERROR_PREFIX, ": ");
|
|
16556
16704
|
}
|
|
16705
|
+
const GROUP_SIGNAL_GRACE_MS = 5e3;
|
|
16706
|
+
const GROUP_EXIT_WAIT_MS = 2e4;
|
|
16707
|
+
function isInstallGroupAlive(pid) {
|
|
16708
|
+
try {
|
|
16709
|
+
process.kill(-pid, 0);
|
|
16710
|
+
return true;
|
|
16711
|
+
} catch (error) {
|
|
16712
|
+
return error.code === "EPERM";
|
|
16713
|
+
}
|
|
16714
|
+
}
|
|
16715
|
+
function readProcessStartStamp(pid) {
|
|
16716
|
+
if (!Number.isInteger(pid) || pid <= 0) return null;
|
|
16717
|
+
try {
|
|
16718
|
+
const result = node_child_process.spawnSync("ps", ["-o", "lstart=", "-p", String(pid)], {
|
|
16719
|
+
encoding: "utf8",
|
|
16720
|
+
timeout: 2e3
|
|
16721
|
+
});
|
|
16722
|
+
if (result.status !== 0 || typeof result.stdout !== "string") return null;
|
|
16723
|
+
const stamp = result.stdout.trim();
|
|
16724
|
+
return stamp.length > 0 ? stamp : null;
|
|
16725
|
+
} catch {
|
|
16726
|
+
return null;
|
|
16727
|
+
}
|
|
16728
|
+
}
|
|
16729
|
+
function signalInstallGroup(pid, signal) {
|
|
16730
|
+
try {
|
|
16731
|
+
process.kill(-pid, signal);
|
|
16732
|
+
return true;
|
|
16733
|
+
} catch {
|
|
16734
|
+
return false;
|
|
16735
|
+
}
|
|
16736
|
+
}
|
|
16737
|
+
async function waitForInstallGroupGone(pid, timeoutMs = GROUP_EXIT_WAIT_MS) {
|
|
16738
|
+
const started = Date.now();
|
|
16739
|
+
while (Date.now() - started < timeoutMs) {
|
|
16740
|
+
if (!isInstallGroupAlive(pid)) return true;
|
|
16741
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
16742
|
+
}
|
|
16743
|
+
return !isInstallGroupAlive(pid);
|
|
16744
|
+
}
|
|
16745
|
+
function runInOwnProcessGroup(input) {
|
|
16746
|
+
const graceMs = input.graceMs ?? GROUP_SIGNAL_GRACE_MS;
|
|
16747
|
+
const log = input.log ?? (() => {
|
|
16748
|
+
});
|
|
16749
|
+
return new Promise((resolve) => {
|
|
16750
|
+
let settled = false;
|
|
16751
|
+
let timedOut = false;
|
|
16752
|
+
let stdout = "";
|
|
16753
|
+
let stderr = "";
|
|
16754
|
+
let groupPid = null;
|
|
16755
|
+
let timer = null;
|
|
16756
|
+
let escalation = null;
|
|
16757
|
+
const finish = (result) => {
|
|
16758
|
+
if (settled) return;
|
|
16759
|
+
settled = true;
|
|
16760
|
+
if (timer) clearTimeout(timer);
|
|
16761
|
+
dropStream(child?.stdout);
|
|
16762
|
+
dropStream(child?.stderr);
|
|
16763
|
+
resolve({ ...result, groupPid });
|
|
16764
|
+
};
|
|
16765
|
+
let child = null;
|
|
16766
|
+
try {
|
|
16767
|
+
child = node_child_process.spawn(input.command, input.args, {
|
|
16768
|
+
detached: true,
|
|
16769
|
+
cwd: input.cwd,
|
|
16770
|
+
...input.env ? { env: input.env } : {},
|
|
16771
|
+
// No inherited stdin: the front end is non-interactive, and an
|
|
16772
|
+
// inherited terminal on a GUI process is a handle to nothing.
|
|
16773
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
16774
|
+
});
|
|
16775
|
+
} catch (error) {
|
|
16776
|
+
log(
|
|
16777
|
+
`Could not start ${input.command}: ${error.message}`,
|
|
16778
|
+
"error"
|
|
16779
|
+
);
|
|
16780
|
+
finish({
|
|
16781
|
+
exitCode: null,
|
|
16782
|
+
stdout,
|
|
16783
|
+
stderr: error.message,
|
|
16784
|
+
ran: false,
|
|
16785
|
+
timedOut: false,
|
|
16786
|
+
groupStillRunning: false
|
|
16787
|
+
});
|
|
16788
|
+
return;
|
|
16789
|
+
}
|
|
16790
|
+
groupPid = child.pid ?? null;
|
|
16791
|
+
if (groupPid !== null) input.onSpawn?.(groupPid);
|
|
16792
|
+
const record = (chunk, stream) => {
|
|
16793
|
+
const text = String(chunk);
|
|
16794
|
+
if (stream === "stdout") {
|
|
16795
|
+
stdout = (stdout + text).slice(-4194304);
|
|
16796
|
+
} else {
|
|
16797
|
+
stderr = (stderr + text).slice(-4194304);
|
|
16798
|
+
}
|
|
16799
|
+
};
|
|
16800
|
+
child.stdout?.on("data", (chunk) => record(chunk, "stdout"));
|
|
16801
|
+
child.stderr?.on("data", (chunk) => record(chunk, "stderr"));
|
|
16802
|
+
child.on("error", (error) => {
|
|
16803
|
+
log(`The updater could not be run: ${error.message}`, "error");
|
|
16804
|
+
finish({
|
|
16805
|
+
exitCode: null,
|
|
16806
|
+
stdout,
|
|
16807
|
+
stderr: stderr.length > 0 ? stderr : error.message,
|
|
16808
|
+
ran: false,
|
|
16809
|
+
timedOut: false,
|
|
16810
|
+
groupStillRunning: false
|
|
16811
|
+
});
|
|
16812
|
+
});
|
|
16813
|
+
child.on("close", (code) => {
|
|
16814
|
+
finish({
|
|
16815
|
+
exitCode: code,
|
|
16816
|
+
stdout,
|
|
16817
|
+
stderr,
|
|
16818
|
+
ran: true,
|
|
16819
|
+
timedOut,
|
|
16820
|
+
groupStillRunning: false
|
|
16821
|
+
});
|
|
16822
|
+
});
|
|
16823
|
+
timer = setTimeout(() => {
|
|
16824
|
+
timedOut = true;
|
|
16825
|
+
log(
|
|
16826
|
+
`The updater did not finish within ${input.timeoutMs}ms; stopping the process group this run started. Its own children (uv, pipx, pip) run inside that group, so signalling only the front end would leave the installer running`,
|
|
16827
|
+
"warn"
|
|
16828
|
+
);
|
|
16829
|
+
if (groupPid !== null) signalInstallGroup(groupPid, "SIGTERM");
|
|
16830
|
+
escalation = setTimeout(() => {
|
|
16831
|
+
if (groupPid === null || !isInstallGroupAlive(groupPid)) return;
|
|
16832
|
+
log(
|
|
16833
|
+
`The updater's process group was still alive ${graceMs}ms after the stop signal; killing it`,
|
|
16834
|
+
"warn"
|
|
16835
|
+
);
|
|
16836
|
+
signalInstallGroup(groupPid, "SIGKILL");
|
|
16837
|
+
}, graceMs);
|
|
16838
|
+
escalation.unref?.();
|
|
16839
|
+
finish({
|
|
16840
|
+
exitCode: null,
|
|
16841
|
+
stdout,
|
|
16842
|
+
stderr,
|
|
16843
|
+
ran: false,
|
|
16844
|
+
timedOut: true,
|
|
16845
|
+
groupStillRunning: groupPid !== null && isInstallGroupAlive(groupPid)
|
|
16846
|
+
});
|
|
16847
|
+
}, input.timeoutMs);
|
|
16848
|
+
});
|
|
16849
|
+
}
|
|
16850
|
+
function dropStream(stream) {
|
|
16851
|
+
const destroy = stream?.destroy;
|
|
16852
|
+
if (typeof destroy === "function") destroy.call(stream);
|
|
16853
|
+
}
|
|
16854
|
+
function serverUpdateFailureRemedy(input) {
|
|
16855
|
+
if (input.rebuildRoute && /REFUSING to release a stale ref/i.test(input.diagnosis)) {
|
|
16856
|
+
return "This checkout is behind its remote, which is what `lop-update` refused to build from: bring the checkout up to date, and the next press here will build it.";
|
|
16857
|
+
}
|
|
16858
|
+
if (input.rebuildRoute) {
|
|
16859
|
+
return "A rebuild changes this install only once it succeeds, so nothing was restarted.";
|
|
16860
|
+
}
|
|
16861
|
+
return "Nothing was restarted: the build that was serving is the build still serving.";
|
|
16862
|
+
}
|
|
16863
|
+
function serverUpdateFailureSentence(input) {
|
|
16864
|
+
const headline = !input.ran ? "The server update did not finish: the installer could not be run to a verdict." : input.exitCode !== 0 ? `The server update did not install: \`${input.rebuildRoute ? "lop-update" : "lop update"}\` exited ${input.exitCode}.` : input.rebuildRoute ? "The rebuild finished but did not change this install: its `.lop-source` still names the same revision." : `The server update to ${input.target ?? "the new release"} did not take effect: the install still reports ${input.after ?? input.before ?? "its previous version"}.`;
|
|
16865
|
+
const stopped = input.groupSurvived ? " The updater was stopped; something it started may still be replacing the install." : "";
|
|
16866
|
+
const pointer = input.diagnosis ? " The installer's own output is below." : " The installer's own output is in the update service log.";
|
|
16867
|
+
const escapeHatch = input.rebuildRoute ? ` You can also run \`${input.updateCommand || "lop-update"}\` yourself in a terminal.` : "";
|
|
16868
|
+
return `${headline}${stopped} ${serverUpdateFailureRemedy(input)}${pointer}${escapeHatch}`;
|
|
16869
|
+
}
|
|
16557
16870
|
const VERSION_LINE_REGEX = /Version:\s*([^\n]+)/;
|
|
16558
16871
|
const VERSION_CLEAN_REGEX = /^v/i;
|
|
16559
16872
|
const BETA_VERSION_REGEX = /v\d+\.\d+\.\d+\.beta\.\d+/;
|
|
16873
|
+
const GLOBAL_UPDATE_TIMEOUT_MS = 15 * 60 * 1e3;
|
|
16874
|
+
const SOURCE_REBUILD_TIMEOUT_MS = 30 * 60 * 1e3;
|
|
16560
16875
|
const BUNDLE_ID_REGEX = /^[\w.-]+$/;
|
|
16561
16876
|
const INSTALL_IN_FLIGHT_RECHECK_MS = 1e4;
|
|
16562
16877
|
const LAUNCHCTL_NOT_LOADED_REGEX = /could not find|no such process/i;
|
|
@@ -16641,10 +16956,6 @@ const DRIFT_ABSENCE_REPORT = {
|
|
|
16641
16956
|
"running-ahead": "the server is running a build NEWER than the install on disk (a downgrade on disk), so it is left alone",
|
|
16642
16957
|
unorderable: "the two readings cannot be ordered against each other, so nothing is restarted on a guess"
|
|
16643
16958
|
};
|
|
16644
|
-
function stderrTail(stderr, maxLines = 4) {
|
|
16645
|
-
const lines = stderr.split("\n").map((line) => line.trim()).filter((line) => line.length > 0);
|
|
16646
|
-
return lines.slice(-maxLines).join("\n").slice(-600);
|
|
16647
|
-
}
|
|
16648
16959
|
function readingOfHealth(health) {
|
|
16649
16960
|
const result = health.result;
|
|
16650
16961
|
return {
|
|
@@ -18814,9 +19125,22 @@ ${heal.removed.join("\n")}`,
|
|
|
18814
19125
|
* machines this check exists for. Null when the prefix carries no console
|
|
18815
19126
|
* script, which is "we could not classify it" rather than a guess.
|
|
18816
19127
|
*/
|
|
18817
|
-
|
|
19128
|
+
/**
|
|
19129
|
+
* The console script an install root exposes, or null when it has none.
|
|
19130
|
+
*
|
|
19131
|
+
* ONE PLACE, because two callers must agree: the identity the app CLASSIFIES an
|
|
19132
|
+
* install by, and the command it RUNS for that install. They used to be derived
|
|
19133
|
+
* separately - the check read `<prefix>/bin/local-operator` while the press ran
|
|
19134
|
+
* whatever the shim resolved to - which is how the two halves came to describe
|
|
19135
|
+
* different installs (review round 5, Q-1 = M2).
|
|
19136
|
+
*/
|
|
19137
|
+
servingConsoleScript(prefix) {
|
|
18818
19138
|
const script = process.platform === "win32" ? path$1.join(prefix, "Scripts", "local-operator.exe") : path$1.join(prefix, "bin", "local-operator");
|
|
18819
|
-
return fs.existsSync(script) ?
|
|
19139
|
+
return fs.existsSync(script) ? script : null;
|
|
19140
|
+
}
|
|
19141
|
+
servingInstallIdentity(prefix) {
|
|
19142
|
+
const script = this.servingConsoleScript(prefix);
|
|
19143
|
+
return script === null ? null : readInstallIdentity(script);
|
|
18820
19144
|
}
|
|
18821
19145
|
/**
|
|
18822
19146
|
* Decide what the backend update prompt may offer for a startup mode.
|
|
@@ -18848,6 +19172,15 @@ ${heal.removed.join("\n")}`,
|
|
|
18848
19172
|
return {
|
|
18849
19173
|
canManageUpdate: false,
|
|
18850
19174
|
updateCommand: "",
|
|
19175
|
+
/*
|
|
19176
|
+
* NO ROUTE AND NO PREFIX: this arm names no installer, which is the whole point
|
|
19177
|
+
* of the sentence above - there is nothing here for the app to run, so the
|
|
19178
|
+
* managed-route fields are empty rather than pointing at the managed tree.
|
|
19179
|
+
* (This arm is upstream's, arrived in the fold; the fields are this branch's,
|
|
19180
|
+
* and the type is what required them to be stated.)
|
|
19181
|
+
*/
|
|
19182
|
+
managedRoute: null,
|
|
19183
|
+
installPrefix: null,
|
|
18851
19184
|
remedy: "This server is running from Local Operator's own managed environment, which no package manager owns - so there is no terminal command that can update it correctly. The app can only update a server it started itself: stop this one, then start Local Operator again and let it start its own.",
|
|
18852
19185
|
detail,
|
|
18853
19186
|
sourceBuild: false,
|
|
@@ -18858,7 +19191,10 @@ ${heal.removed.join("\n")}`,
|
|
|
18858
19191
|
}
|
|
18859
19192
|
const servingIdentity = serving.prefix !== null ? this.servingInstallIdentity(serving.prefix) : null;
|
|
18860
19193
|
const identity = servingIdentity ?? await this.resolveInstallIdentity();
|
|
18861
|
-
const plan = resolveGlobalInstallPlan({
|
|
19194
|
+
const plan = resolveGlobalInstallPlan({
|
|
19195
|
+
identity,
|
|
19196
|
+
sourceRebuild: resolveCommandPath("lop-update")
|
|
19197
|
+
});
|
|
18862
19198
|
logger.info(
|
|
18863
19199
|
`External backend install (${startupMode}): ${plan.detail}; remedy is \`${plan.updateCommand || "nothing the app can name"}\`; install version ${serving.version ?? identity.version ?? "unreadable"}`,
|
|
18864
19200
|
LogFileType.UPDATE_SERVICE
|
|
@@ -18875,7 +19211,9 @@ ${heal.removed.join("\n")}`,
|
|
|
18875
19211
|
* Either reading of the serving root proves the subject: its dist-info's
|
|
18876
19212
|
* version, or the identity read from its own console script.
|
|
18877
19213
|
*/
|
|
18878
|
-
installedInstallVersionDescribesServingInstall: serving.version !== null || servingIdentity !== null
|
|
19214
|
+
installedInstallVersionDescribesServingInstall: serving.version !== null || servingIdentity !== null,
|
|
19215
|
+
managedRoute: plan.managedRoute,
|
|
19216
|
+
installPrefix: plan.installPrefix
|
|
18879
19217
|
};
|
|
18880
19218
|
}
|
|
18881
19219
|
return {
|
|
@@ -18913,9 +19251,25 @@ ${heal.removed.join("\n")}`,
|
|
|
18913
19251
|
* construction - it is read FROM the root the server named - so it describes the
|
|
18914
19252
|
* subject whenever there is a reading at all.
|
|
18915
19253
|
*/
|
|
18916
|
-
installedInstallVersionDescribesServingInstall: serving.version !== null
|
|
19254
|
+
installedInstallVersionDescribesServingInstall: serving.version !== null,
|
|
19255
|
+
managedRoute: "entry-point",
|
|
19256
|
+
installPrefix: null
|
|
18917
19257
|
};
|
|
18918
19258
|
}
|
|
19259
|
+
/**
|
|
19260
|
+
* `.lop-source` for the install currently resolved, read from a fresh resolution.
|
|
19261
|
+
*
|
|
19262
|
+
* Re-resolved on every call rather than captured with the plan, for the reason
|
|
19263
|
+
* this whole path re-reads its evidence: between a check and a click the install
|
|
19264
|
+
* can move (a generations swap re-points `current`, and `lop-update` rewrites the
|
|
19265
|
+
* uv tool root), so a prefix held from minutes ago can name a tree nothing is
|
|
19266
|
+
* using any more. The reading is what `didSourceRebuildLand` compares, in both
|
|
19267
|
+
* directions.
|
|
19268
|
+
*/
|
|
19269
|
+
readRebuildMarkerState(installPath) {
|
|
19270
|
+
const identity = readInstallIdentity(installPath);
|
|
19271
|
+
return readSourceMarkerState(identity.venvPrefix ?? null);
|
|
19272
|
+
}
|
|
18919
19273
|
/**
|
|
18920
19274
|
* Path the `local-operator` shim resolves to, or null when it is not installed.
|
|
18921
19275
|
*
|
|
@@ -19684,15 +20038,24 @@ ${heal.removed.join("\n")}`,
|
|
|
19684
20038
|
}
|
|
19685
20039
|
/**
|
|
19686
20040
|
* The version the resolved global install reports on disk, or null.
|
|
20041
|
+
* The version an install reports on disk, or null.
|
|
20042
|
+
*
|
|
20043
|
+
* Shell-free, and deliberately a different read from the running backend's `/health`:
|
|
20044
|
+
* the install is what an update moves, and immediately after one it is the only reading
|
|
20045
|
+
* that has moved - the daemon lags until it is restarted.
|
|
19687
20046
|
*
|
|
19688
|
-
*
|
|
19689
|
-
*
|
|
19690
|
-
*
|
|
19691
|
-
*
|
|
19692
|
-
* the
|
|
20047
|
+
* THE INSTALL IS A REQUIRED ARGUMENT, and that is a fix rather than a style. This read
|
|
20048
|
+
* used to default to `resolveLocalOperatorPath()` - the shim - and that default is how
|
|
20049
|
+
* the same seam kept arriving: a caller that forgot which install its attempt had run
|
|
20050
|
+
* silently read the shim instead, and four review rounds found it one site at a time
|
|
20051
|
+
* (the press against the check, the two evidence reads, then the unattended
|
|
20052
|
+
* reconciliation). A required argument turns that mistake into a compile error rather
|
|
20053
|
+
* than into a plausible number. The one caller with a record and no path - the
|
|
20054
|
+
* reconciliation reading a marker an older build wrote - names its fallback explicitly
|
|
20055
|
+
* and logs it.
|
|
19693
20056
|
*/
|
|
19694
|
-
|
|
19695
|
-
return readInstallIdentity(
|
|
20057
|
+
readInstallVersionAt(installPath) {
|
|
20058
|
+
return readInstallIdentity(installPath)?.version ?? null;
|
|
19696
20059
|
}
|
|
19697
20060
|
/**
|
|
19698
20061
|
* Run the install's own front end: `<resolved shim> update`.
|
|
@@ -19718,23 +20081,36 @@ ${heal.removed.join("\n")}`,
|
|
|
19718
20081
|
* cannot land inside the sealed bundle. `shell: true` is never used - this is a
|
|
19719
20082
|
* resolved shim path, not a command line.
|
|
19720
20083
|
*/
|
|
19721
|
-
async runGlobalUpdate(
|
|
20084
|
+
async runGlobalUpdate(command, timeoutMs, onSpawn) {
|
|
19722
20085
|
const updatePath = installerSearchPath(process.env, os.homedir());
|
|
19723
20086
|
logger.info(
|
|
19724
|
-
`Executing the install's own updater: ${
|
|
20087
|
+
`Executing the install's own updater: ${command.path} ${command.args.join(" ")} (PATH: ${updatePath})`,
|
|
19725
20088
|
LogFileType.UPDATE_SERVICE
|
|
19726
20089
|
);
|
|
19727
|
-
const run = await
|
|
19728
|
-
|
|
19729
|
-
|
|
20090
|
+
const run = await runInOwnProcessGroup({
|
|
20091
|
+
command: command.path,
|
|
20092
|
+
args: command.args,
|
|
20093
|
+
timeoutMs,
|
|
20094
|
+
// A neutral working directory, never the app's resources dir, so an updater
|
|
20095
|
+
// that resolves a relative path from where it stands cannot land inside the
|
|
20096
|
+
// sealed bundle.
|
|
20097
|
+
cwd: os.homedir(),
|
|
20098
|
+
env: { ...this.pythonSpawnEnv(), PATH: updatePath },
|
|
20099
|
+
onSpawn,
|
|
20100
|
+
log: (line, level) => {
|
|
20101
|
+
if (level === "error") logger.error(line, LogFileType.UPDATE_SERVICE);
|
|
20102
|
+
else if (level === "warn")
|
|
20103
|
+
logger.warn(line, LogFileType.UPDATE_SERVICE);
|
|
20104
|
+
else logger.info(line, LogFileType.UPDATE_SERVICE);
|
|
20105
|
+
}
|
|
19730
20106
|
});
|
|
19731
20107
|
logger.info(
|
|
19732
|
-
|
|
20108
|
+
`${command.path} stdout: ${run.stdout.trim() || "(empty)"}`,
|
|
19733
20109
|
LogFileType.UPDATE_SERVICE
|
|
19734
20110
|
);
|
|
19735
20111
|
if (run.stderr.trim().length > 0) {
|
|
19736
20112
|
logger.warn(
|
|
19737
|
-
|
|
20113
|
+
`${command.path} stderr: ${run.stderr.trim()}`,
|
|
19738
20114
|
LogFileType.UPDATE_SERVICE
|
|
19739
20115
|
);
|
|
19740
20116
|
}
|
|
@@ -19770,6 +20146,36 @@ ${heal.removed.join("\n")}`,
|
|
|
19770
20146
|
* exactly as the pip path does (`didUpgradeLand`).
|
|
19771
20147
|
*/
|
|
19772
20148
|
async updateGlobalInstall(backend, plan, target2) {
|
|
20149
|
+
const recorded = readPendingServerUpdateMarker(this.markerDir());
|
|
20150
|
+
const recordedPid = recorded?.groupPid ?? null;
|
|
20151
|
+
const recordedGroupAlive = recordedPid !== null && isInstallGroupAlive(recordedPid);
|
|
20152
|
+
const outcome = evaluatePendingServerUpdateMarker({
|
|
20153
|
+
marker: recorded,
|
|
20154
|
+
groupAlive: recordedGroupAlive,
|
|
20155
|
+
liveGroupStamp: recordedGroupAlive && recordedPid !== null ? readProcessStartStamp(recordedPid) : null
|
|
20156
|
+
});
|
|
20157
|
+
if (outcome.kind === "expired") {
|
|
20158
|
+
logger.warn(
|
|
20159
|
+
`A recorded server update (${outcome.marker.startedAt}) is not running any more (${outcome.reason}); clearing the record so this attempt can proceed`,
|
|
20160
|
+
LogFileType.UPDATE_SERVICE
|
|
20161
|
+
);
|
|
20162
|
+
clearPendingServerUpdateMarker(this.markerDir());
|
|
20163
|
+
}
|
|
20164
|
+
if (outcome.kind === "running") {
|
|
20165
|
+
logger.warn(
|
|
20166
|
+
`A server update recorded by an earlier launch is still running (group ${outcome.marker.groupPid}, ${outcome.reason}, started ${outcome.marker.startedAt}); refusing to start a second beside it`,
|
|
20167
|
+
LogFileType.UPDATE_SERVICE
|
|
20168
|
+
);
|
|
20169
|
+
const startedAtMs = Date.parse(outcome.marker.startedAt);
|
|
20170
|
+
const ageMinutes = Number.isFinite(startedAtMs) ? Math.max(0, Math.round((Date.now() - startedAtMs) / 6e4)) : null;
|
|
20171
|
+
const age = ageMinutes === null ? "at a time that could not be read" : ageMinutes < 1 ? "moments ago" : `${ageMinutes} minute${ageMinutes === 1 ? "" : "s"} ago`;
|
|
20172
|
+
this.sendToRenderer("backend-update-error", {
|
|
20173
|
+
message: `A server update this app started ${age} is still running (process group ${outcome.marker.groupPid}), so a second one will not be started beside it. If that update is no longer responding, the update service log says what it was doing.`,
|
|
20174
|
+
phase: "update",
|
|
20175
|
+
logPath: serverUpdateLogPath()
|
|
20176
|
+
});
|
|
20177
|
+
return false;
|
|
20178
|
+
}
|
|
19773
20179
|
if (this.serverUpdateInFlight) {
|
|
19774
20180
|
logger.warn(
|
|
19775
20181
|
"A global install update is already running; refusing to start a second beside it",
|
|
@@ -19783,16 +20189,75 @@ ${heal.removed.join("\n")}`,
|
|
|
19783
20189
|
return false;
|
|
19784
20190
|
}
|
|
19785
20191
|
this.serverUpdateInFlight = true;
|
|
20192
|
+
let handedOff = false;
|
|
19786
20193
|
try {
|
|
19787
|
-
|
|
20194
|
+
const landed = await this.runGlobalUpdateAttempt(backend, plan, target2);
|
|
20195
|
+
const stillRunning = this.recordedUpdateStillRunning();
|
|
20196
|
+
if (stillRunning !== null) {
|
|
20197
|
+
handedOff = true;
|
|
20198
|
+
void this.holdGlobalInstallGuardUntilGroupGone(stillRunning);
|
|
20199
|
+
}
|
|
20200
|
+
return landed;
|
|
19788
20201
|
} finally {
|
|
19789
|
-
this.serverUpdateInFlight = false;
|
|
20202
|
+
if (!handedOff) this.serverUpdateInFlight = false;
|
|
19790
20203
|
}
|
|
19791
20204
|
}
|
|
20205
|
+
/**
|
|
20206
|
+
* The group a record on disk still names, or null when nothing is running.
|
|
20207
|
+
*
|
|
20208
|
+
* Read rather than passed, because it is the SAME evidence the next launch reads:
|
|
20209
|
+
* a guard that decided from an in-memory value would disagree with the launch
|
|
20210
|
+
* that follows a crash, which is the whole class this pass is closing.
|
|
20211
|
+
*/
|
|
20212
|
+
recordedUpdateStillRunning() {
|
|
20213
|
+
const recorded = readPendingServerUpdateMarker(this.markerDir());
|
|
20214
|
+
const pid = recorded?.groupPid ?? null;
|
|
20215
|
+
if (pid === null) return null;
|
|
20216
|
+
const alive = isInstallGroupAlive(pid);
|
|
20217
|
+
const outcome = evaluatePendingServerUpdateMarker({
|
|
20218
|
+
marker: recorded,
|
|
20219
|
+
groupAlive: alive,
|
|
20220
|
+
liveGroupStamp: alive ? readProcessStartStamp(pid) : null
|
|
20221
|
+
});
|
|
20222
|
+
return outcome.kind === "running" ? pid : null;
|
|
20223
|
+
}
|
|
20224
|
+
/**
|
|
20225
|
+
* Hold the install guard until a signalled group is really gone.
|
|
20226
|
+
*
|
|
20227
|
+
* Fire-and-forget on purpose: the verdict has already been delivered to the user,
|
|
20228
|
+
* and awaiting this would put a descendant that outlived its stop signal back in
|
|
20229
|
+
* the panel's path. The record is cleared only once the group is gone, because
|
|
20230
|
+
* until then it is the evidence that an updater is still writing into the install
|
|
20231
|
+
* root - the thing another launch has to see.
|
|
20232
|
+
*/
|
|
20233
|
+
async holdGlobalInstallGuardUntilGroupGone(groupPid) {
|
|
20234
|
+
let gone = await waitForInstallGroupGone(groupPid, GROUP_EXIT_WAIT_MS);
|
|
20235
|
+
while (!gone) {
|
|
20236
|
+
logger.warn(
|
|
20237
|
+
`The updater's process group ${groupPid} outlived the stop signal and the ${Math.round(GROUP_EXIT_WAIT_MS / 1e3)}s wait; another server update will not be started until it exits`,
|
|
20238
|
+
LogFileType.UPDATE_SERVICE
|
|
20239
|
+
);
|
|
20240
|
+
await new Promise((resolve) => setTimeout(resolve, 5e3));
|
|
20241
|
+
gone = !isInstallGroupAlive(groupPid);
|
|
20242
|
+
}
|
|
20243
|
+
clearPendingServerUpdateMarker(this.markerDir());
|
|
20244
|
+
this.serverUpdateInFlight = false;
|
|
20245
|
+
}
|
|
19792
20246
|
/** The attempt itself, under the guard above. */
|
|
19793
20247
|
async runGlobalUpdateAttempt(backend, plan, target2) {
|
|
19794
20248
|
const consolePath = this.resolveLocalOperatorPath();
|
|
19795
|
-
|
|
20249
|
+
const rebuildPath = resolveCommandPath("lop-update");
|
|
20250
|
+
const running = await this.readRunningBackend();
|
|
20251
|
+
const serving = this.readServingInstall(running);
|
|
20252
|
+
const servingScript = serving.prefix ? this.servingConsoleScript(serving.prefix) : null;
|
|
20253
|
+
const servingIdentity = serving.prefix ? this.servingInstallIdentity(serving.prefix) : null;
|
|
20254
|
+
const installPath = servingScript ?? consolePath;
|
|
20255
|
+
const freshPlan = resolveGlobalInstallPlan({
|
|
20256
|
+
identity: servingIdentity ?? readInstallIdentity(installPath),
|
|
20257
|
+
sourceRebuild: rebuildPath
|
|
20258
|
+
});
|
|
20259
|
+
const rebuildRoute = freshPlan.managedRoute === "source-build" && rebuildPath !== null;
|
|
20260
|
+
if (!consolePath && !rebuildRoute) {
|
|
19796
20261
|
this.sendToRenderer("backend-update-error", {
|
|
19797
20262
|
message: "The global install could not be found on this machine, so the update did not run. See the update service log.",
|
|
19798
20263
|
phase: "update",
|
|
@@ -19800,28 +20265,83 @@ ${heal.removed.join("\n")}`,
|
|
|
19800
20265
|
});
|
|
19801
20266
|
return false;
|
|
19802
20267
|
}
|
|
19803
|
-
const
|
|
19804
|
-
this.
|
|
19805
|
-
|
|
20268
|
+
const budgetMs = rebuildRoute ? SOURCE_REBUILD_TIMEOUT_MS : GLOBAL_UPDATE_TIMEOUT_MS;
|
|
20269
|
+
const before = this.readInstallVersionAt(installPath) ?? plan.installedInstallVersion;
|
|
20270
|
+
const markerBefore = rebuildRoute ? this.readRebuildMarkerState(installPath) : null;
|
|
20271
|
+
this.sendToRenderer("backend-update-progress", {
|
|
20272
|
+
phase: "installing",
|
|
20273
|
+
sourceRebuild: rebuildRoute
|
|
20274
|
+
});
|
|
20275
|
+
const marker = {
|
|
19806
20276
|
before,
|
|
19807
20277
|
target: target2,
|
|
19808
|
-
startedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
19809
|
-
|
|
19810
|
-
|
|
19811
|
-
|
|
19812
|
-
|
|
19813
|
-
|
|
20278
|
+
startedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
20279
|
+
// The record's own budget, so a reader can tell a run that is going from a
|
|
20280
|
+
// record that outlived it without consulting a clock the app chose later.
|
|
20281
|
+
deadlineAt: new Date(Date.now() + budgetMs).toISOString(),
|
|
20282
|
+
groupPid: null,
|
|
20283
|
+
groupStartedAt: null,
|
|
20284
|
+
/*
|
|
20285
|
+
* The install this attempt runs, recorded so a later launch does not have to resolve
|
|
20286
|
+
* one of its own: the reconciliation reads this, and a marker without it (an older
|
|
20287
|
+
* build's) is the only case that falls back.
|
|
20288
|
+
*/
|
|
20289
|
+
installPath
|
|
20290
|
+
};
|
|
20291
|
+
writePendingServerUpdateMarker(this.markerDir(), marker);
|
|
20292
|
+
const run = await this.runGlobalUpdate(
|
|
20293
|
+
rebuildRoute && rebuildPath !== null ? { path: rebuildPath, args: [] } : (
|
|
20294
|
+
/*
|
|
20295
|
+
* THE SERVING INSTALL'S OWN FRONT END when it has one, and the shim's
|
|
20296
|
+
* resolution only as the fallback: the identity above is that script's, so
|
|
20297
|
+
* running anything else would update one install and verify another - the
|
|
20298
|
+
* seam this case classifies (review round 5, Q-1 = M2).
|
|
20299
|
+
*/
|
|
20300
|
+
{ path: installPath ?? "", args: ["update"] }
|
|
20301
|
+
),
|
|
20302
|
+
budgetMs,
|
|
20303
|
+
(pid) => {
|
|
20304
|
+
writePendingServerUpdateMarker(this.markerDir(), {
|
|
20305
|
+
before,
|
|
20306
|
+
target: target2,
|
|
20307
|
+
startedAt: marker.startedAt,
|
|
20308
|
+
deadlineAt: marker.deadlineAt,
|
|
20309
|
+
groupPid: pid,
|
|
20310
|
+
groupStartedAt: readProcessStartStamp(pid),
|
|
20311
|
+
installPath
|
|
20312
|
+
});
|
|
20313
|
+
}
|
|
20314
|
+
);
|
|
20315
|
+
const after = this.readInstallVersionAt(installPath);
|
|
20316
|
+
const markerAfter = rebuildRoute ? this.readRebuildMarkerState(installPath) : null;
|
|
20317
|
+
const groupSurvived = run.timedOut && run.groupStillRunning && run.groupPid !== null;
|
|
20318
|
+
if (!groupSurvived) clearPendingServerUpdateMarker(this.markerDir());
|
|
20319
|
+
const landed = run.exitCode === 0 && (rebuildRoute ? didSourceRebuildLand({
|
|
20320
|
+
before: markerBefore ?? { ref: null, mtimeMs: null },
|
|
20321
|
+
after: markerAfter ?? { ref: null, mtimeMs: null }
|
|
20322
|
+
}) : didUpgradeLand({ before, after, target: target2 }));
|
|
19814
20323
|
if (!landed) {
|
|
19815
|
-
const
|
|
19816
|
-
const
|
|
20324
|
+
const running2 = await this.getInstalledBackendVersion();
|
|
20325
|
+
const diagnosis = installDiagnosisLines(
|
|
20326
|
+
run.stderr.length > 0 ? run.stderr : run.stdout
|
|
20327
|
+
);
|
|
19817
20328
|
logger.error(
|
|
19818
|
-
|
|
20329
|
+
`${rebuildRoute ? "Source rebuild" : "Global install update"} did not land (exited ${run.exitCode}, ran ${run.ran}${groupSurvived ? `, group ${run.groupPid} still alive` : ""}): ${before ?? "unknown"} -> ${after ?? "unknown"}${rebuildRoute ? `, marker ${markerBefore?.ref ?? "none"} -> ${markerAfter?.ref ?? "none"}` : ""}, running backend reports ${running2 ?? "no reading"}`,
|
|
19819
20330
|
LogFileType.UPDATE_SERVICE
|
|
19820
20331
|
);
|
|
19821
20332
|
this.sendToRenderer("backend-update-error", {
|
|
19822
|
-
message:
|
|
19823
|
-
|
|
19824
|
-
|
|
20333
|
+
message: serverUpdateFailureSentence({
|
|
20334
|
+
rebuildRoute,
|
|
20335
|
+
ran: run.ran,
|
|
20336
|
+
exitCode: run.exitCode,
|
|
20337
|
+
groupSurvived,
|
|
20338
|
+
diagnosis,
|
|
20339
|
+
target: target2,
|
|
20340
|
+
after,
|
|
20341
|
+
before,
|
|
20342
|
+
updateCommand: freshPlan.updateCommand ?? null
|
|
20343
|
+
}),
|
|
20344
|
+
installerOutput: diagnosis || void 0,
|
|
19825
20345
|
phase: "update",
|
|
19826
20346
|
logPath: serverUpdateLogPath()
|
|
19827
20347
|
});
|
|
@@ -19833,14 +20353,14 @@ ${tail}` : ""}`,
|
|
|
19833
20353
|
);
|
|
19834
20354
|
if (backend.isUsingExternalBackend()) {
|
|
19835
20355
|
const health = await this.getInstalledBackendVersion();
|
|
19836
|
-
const
|
|
20356
|
+
const running2 = this.eventRunningVersion(health);
|
|
19837
20357
|
logger.info(
|
|
19838
|
-
`Install moved to ${after ?? "unknown"}; the attached backend still reports ${
|
|
20358
|
+
`Install moved to ${after ?? "unknown"}; the attached backend still reports ${running2 ?? "no reading"}${running2 === health ? "" : ` (its own record; /health answers ${health ?? "no reading"})`}`,
|
|
19839
20359
|
LogFileType.UPDATE_SERVICE
|
|
19840
20360
|
);
|
|
19841
20361
|
this.sendToRenderer("backend-update-completed", {
|
|
19842
20362
|
installVersion: after,
|
|
19843
|
-
runningVersion:
|
|
20363
|
+
runningVersion: running2,
|
|
19844
20364
|
restarted: false,
|
|
19845
20365
|
restartable: this.backendIsAppOwned(),
|
|
19846
20366
|
/*
|
|
@@ -19888,7 +20408,14 @@ ${tail}` : ""}`,
|
|
|
19888
20408
|
return false;
|
|
19889
20409
|
}
|
|
19890
20410
|
}
|
|
19891
|
-
const reported = await this.waitForBackendVersion(
|
|
20411
|
+
const reported = await this.waitForBackendVersion(
|
|
20412
|
+
// The rebuild keeps the version, so there is no target to hold it to: the
|
|
20413
|
+
// version the restarted daemon reports is the CHECKOUT's, and holding it to
|
|
20414
|
+
// the release the app offered would fail a restart that landed (the same
|
|
20415
|
+
// reasoning as `sourceBuild` in the prompt). What is held is that the daemon
|
|
20416
|
+
// is back and answering at all.
|
|
20417
|
+
rebuildRoute ? null : target2
|
|
20418
|
+
);
|
|
19892
20419
|
if (reported === null) {
|
|
19893
20420
|
logger.error(
|
|
19894
20421
|
`Backend restarted onto the updated install but did not report ${target2 ?? "the target version"}`,
|
|
@@ -19931,7 +20458,30 @@ ${tail}` : ""}`,
|
|
|
19931
20458
|
async reportUnattendedServerUpdate() {
|
|
19932
20459
|
const marker = readPendingServerUpdateMarker(this.markerDir());
|
|
19933
20460
|
if (!marker) return;
|
|
19934
|
-
const
|
|
20461
|
+
const recordedPid = marker.groupPid ?? null;
|
|
20462
|
+
const recordedAlive = recordedPid !== null && isInstallGroupAlive(recordedPid);
|
|
20463
|
+
const outcome = evaluatePendingServerUpdateMarker({
|
|
20464
|
+
marker,
|
|
20465
|
+
groupAlive: recordedAlive,
|
|
20466
|
+
liveGroupStamp: recordedAlive && recordedPid !== null ? readProcessStartStamp(recordedPid) : null
|
|
20467
|
+
});
|
|
20468
|
+
if (outcome.kind === "running") {
|
|
20469
|
+
logger.warn(
|
|
20470
|
+
`A server update recorded at ${marker.startedAt} is still running (group ${recordedPid}, ${outcome.reason}); leaving its record in place`,
|
|
20471
|
+
LogFileType.UPDATE_SERVICE
|
|
20472
|
+
);
|
|
20473
|
+
return;
|
|
20474
|
+
}
|
|
20475
|
+
const recordPath = marker.installPath ?? null;
|
|
20476
|
+
const after = this.readInstallVersionAt(
|
|
20477
|
+
recordPath ?? this.resolveLocalOperatorPath()
|
|
20478
|
+
);
|
|
20479
|
+
if (recordPath === null) {
|
|
20480
|
+
logger.warn(
|
|
20481
|
+
`The update record from ${marker.startedAt} names no install (written by an older build); reading the shim's resolution instead`,
|
|
20482
|
+
LogFileType.UPDATE_SERVICE
|
|
20483
|
+
);
|
|
20484
|
+
}
|
|
19935
20485
|
clearPendingServerUpdateMarker(this.markerDir());
|
|
19936
20486
|
if (!after || after === marker.before) {
|
|
19937
20487
|
logger.info(
|