local-operator-ui 0.28.2 → 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.
Files changed (69) hide show
  1. package/out/main/index.js +613 -63
  2. package/out/renderer/assets/{_basePickBy-BiVlVvaz.js → _basePickBy-BBP0F16i.js} +1 -1
  3. package/out/renderer/assets/{_baseUniq-B7hmLjHp.js → _baseUniq-DjxRRM-q.js} +1 -1
  4. package/out/renderer/assets/{agent-details-page-Bo1iXiw2.js → agent-details-page-b4Jm7Fqp.js} +1 -1
  5. package/out/renderer/assets/{agent-hub-page-DpYsf3xz.js → agent-hub-page-nCGrd10n.js} +1 -1
  6. package/out/renderer/assets/{agents-page-DpmiwHFn.js → agents-page-DLjX0IMj.js} +2 -2
  7. package/out/renderer/assets/{arc-zQfKMj24.js → arc-C2DRbjeQ.js} +1 -1
  8. package/out/renderer/assets/{architectureDiagram-IEHRJDOE-B7ZATeGZ.js → architectureDiagram-IEHRJDOE-Bu3QEQkj.js} +1 -1
  9. package/out/renderer/assets/{blockDiagram-JOT3LUYC-CHlXcfDj.js → blockDiagram-JOT3LUYC-B4dcpLda.js} +1 -1
  10. package/out/renderer/assets/browser-page-XjS6AJWX.js +1 -0
  11. package/out/renderer/assets/{c4Diagram-VJAJSXHY-Cqc7SCB2.js → c4Diagram-VJAJSXHY-CM93Qt3c.js} +1 -1
  12. package/out/renderer/assets/channel-BhgbMo9c.js +1 -0
  13. package/out/renderer/assets/{chunk-4BMEZGHF-Bsl_7NtA.js → chunk-4BMEZGHF-BoCakpR9.js} +1 -1
  14. package/out/renderer/assets/{chunk-A2AXSNBT-ggoGKGXQ.js → chunk-A2AXSNBT-fPE0V_zK.js} +1 -1
  15. package/out/renderer/assets/{chunk-AEK57VVT-DqWWmU8P.js → chunk-AEK57VVT-PeU0U0Jr.js} +1 -1
  16. package/out/renderer/assets/{chunk-D6G4REZN-BoMbjS6y.js → chunk-D6G4REZN-dqXsUJuN.js} +1 -1
  17. package/out/renderer/assets/{chunk-RZ5BOZE2-is_C2hBC.js → chunk-RZ5BOZE2-iOEMmjI6.js} +1 -1
  18. package/out/renderer/assets/{chunk-XZIHB7SX-ld86pApa.js → chunk-XZIHB7SX-DNMxuK8c.js} +1 -1
  19. package/out/renderer/assets/classDiagram-GIVACNV2-BFMzYHo3.js +1 -0
  20. package/out/renderer/assets/classDiagram-v2-COTLJTTW-BFMzYHo3.js +1 -0
  21. package/out/renderer/assets/clone-AF0jx5V9.js +1 -0
  22. package/out/renderer/assets/{compact-pagination-BSC2TmbH.js → compact-pagination-CdlSy_zE.js} +1 -1
  23. package/out/renderer/assets/{dagre-OKDRZEBW-32aJrB_O.js → dagre-OKDRZEBW-CLEK8caE.js} +1 -1
  24. package/out/renderer/assets/{diagram-SSKATNLV-Amr2xP2t.js → diagram-SSKATNLV-Yql0QmJX.js} +1 -1
  25. package/out/renderer/assets/{diagram-VNBRO52H-Bc_-ILS5.js → diagram-VNBRO52H-o_VgZJcg.js} +1 -1
  26. package/out/renderer/assets/{erDiagram-Q7BY3M3F-ZwSCjJ6e.js → erDiagram-Q7BY3M3F-COJrT7PC.js} +1 -1
  27. package/out/renderer/assets/{flowDiagram-4HSFHLVR-Dql7kx-Z.js → flowDiagram-4HSFHLVR-DoxahilY.js} +1 -1
  28. package/out/renderer/assets/{ganttDiagram-APWFNJXF-DSWWoa_C.js → ganttDiagram-APWFNJXF-Bm7z-0v8.js} +1 -1
  29. package/out/renderer/assets/{gitGraphDiagram-7IBYFJ6S-cbUhz4PY.js → gitGraphDiagram-7IBYFJ6S-CpwCBQRX.js} +1 -1
  30. package/out/renderer/assets/{graph-CojCeg8k.js → graph-DR0IUFTR.js} +1 -1
  31. package/out/renderer/assets/icon-B0HMKFlq.css +1 -0
  32. package/out/renderer/assets/{index-YYBGshw-.js → index-DnW_kgMc.js} +380 -375
  33. package/out/renderer/assets/{index-DP42nWkP.js → index-KI186JLX.js} +1 -1
  34. package/out/renderer/assets/{index-DgY41JzN.js → index-NgjUzrqT.js} +1 -1
  35. package/out/renderer/assets/{infoDiagram-PH2N3AL5-DCWE8TqR.js → infoDiagram-PH2N3AL5-D2IPdCqr.js} +1 -1
  36. package/out/renderer/assets/{installer-BH6UzMkw.js → installer-DYouFhce.js} +1 -1
  37. package/out/renderer/assets/{journeyDiagram-U35MCT3I-BVApHQpT.js → journeyDiagram-U35MCT3I-kZ8pfyqw.js} +1 -1
  38. package/out/renderer/assets/{kanban-definition-NDS4AKOZ-BHlQbvEk.js → kanban-definition-NDS4AKOZ-vULF1Xsx.js} +1 -1
  39. package/out/renderer/assets/{layout-C68weM2q.js → layout-BTG-f-9Y.js} +1 -1
  40. package/out/renderer/assets/{legacy-agents-page-DySZFeGz.js → legacy-agents-page-F6UxsNw3.js} +1 -1
  41. package/out/renderer/assets/{mermaid.core-mZpI-6tM.js → mermaid.core-C9ejm8if.js} +5 -5
  42. package/out/renderer/assets/{mindmap-definition-ALO5MXBD-CUBn2plb.js → mindmap-definition-ALO5MXBD-DoPXZY6O.js} +1 -1
  43. package/out/renderer/assets/{page-header-BkCnUAVS.js → page-header-ByGWC-Bb.js} +1 -1
  44. package/out/renderer/assets/{parseISO-BUcHeIn4.js → parseISO-DEXiCU5D.js} +1 -1
  45. package/out/renderer/assets/{pieDiagram-IB7DONF6-D9khOoXJ.js → pieDiagram-IB7DONF6-DFOQu0lV.js} +1 -1
  46. package/out/renderer/assets/{quadrantDiagram-7GDLP6J5-Ckc_HLn6.js → quadrantDiagram-7GDLP6J5-5HNeqaT-.js} +1 -1
  47. package/out/renderer/assets/{radar-MK3ICKWK-E_AG5HZR.js → radar-MK3ICKWK-B4kS90ER.js} +1 -1
  48. package/out/renderer/assets/{radient-auth-buttons-DWMBn27v.js → radient-auth-buttons-D5aeH47J.js} +1 -1
  49. package/out/renderer/assets/{requirementDiagram-KVF5MWMF-D4z3bPKG.js → requirementDiagram-KVF5MWMF-BccyNVCD.js} +1 -1
  50. package/out/renderer/assets/{sankeyDiagram-QLVOVGJD-CdqXkAPm.js → sankeyDiagram-QLVOVGJD-CQQdfrb1.js} +1 -1
  51. package/out/renderer/assets/{schedules-page-DKdkQ-bz.js → schedules-page-BVR4d6SM.js} +1 -1
  52. package/out/renderer/assets/{sequenceDiagram-X6HHIX6F-cRXMF1u2.js → sequenceDiagram-X6HHIX6F-BqHg5YQ3.js} +1 -1
  53. package/out/renderer/assets/{settings-page-DxOCG1h9.js → settings-page-DjY4p8xo.js} +1 -1
  54. package/out/renderer/assets/{stateDiagram-DGXRK772-rwTz9XiH.js → stateDiagram-DGXRK772-b7i6Bt1H.js} +1 -1
  55. package/out/renderer/assets/stateDiagram-v2-YXO3MK2T-D7Rngykw.js +1 -0
  56. package/out/renderer/assets/{timeline-definition-BDJGKUSR-DjpmacOZ.js → timeline-definition-BDJGKUSR-B5v_2PQx.js} +1 -1
  57. package/out/renderer/assets/{use-agent-like-mutation-BgxlHPcf.js → use-agent-like-mutation-C6zdtkZo.js} +1 -1
  58. package/out/renderer/assets/{xychartDiagram-VJFVF3MP-C-PscNet.js → xychartDiagram-VJFVF3MP-Djcwglte.js} +1 -1
  59. package/out/renderer/index.html +3 -3
  60. package/out/renderer/installer.html +3 -3
  61. package/package.json +2 -2
  62. package/out/renderer/assets/browser-page-XN0o_4Fc.js +0 -1
  63. package/out/renderer/assets/channel-DZNoINil.js +0 -1
  64. package/out/renderer/assets/classDiagram-GIVACNV2-CWYh2jD8.js +0 -1
  65. package/out/renderer/assets/classDiagram-v2-COTLJTTW-CWYh2jD8.js +0 -1
  66. package/out/renderer/assets/clone-3puJuEer.js +0 -1
  67. package/out/renderer/assets/icon-B9Dq7y-Q.css +0 -1
  68. package/out/renderer/assets/stateDiagram-v2-YXO3MK2T-DK-O9XV3.js +0 -1
  69. /package/out/renderer/assets/{icon-BJ1diQ13.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 provenance = sourceBuild ? " Built from source on this machine, so `lop update` installs the published release over it - the harness prints the same notice." : "";
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 === "sessions.seen") {
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
- servingInstallIdentity(prefix) {
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) ? readInstallIdentity(script) : null;
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({ identity });
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
- * Shell-free, and deliberately a different read from the running backend's
19689
- * `/health`: the install is what an update moves, and immediately after one it
19690
- * is the only reading that has moved - the daemon lags until it is restarted.
19691
- * Read through the same resolution the plan classified, because it is half of
19692
- * the evidence that the install changed at all.
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
- readGlobalInstallVersion() {
19695
- return readInstallIdentity(this.resolveLocalOperatorPath())?.version ?? null;
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(consolePath) {
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: ${consolePath} update (PATH: ${updatePath})`,
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 runCommand(consolePath, ["update"], {
19728
- timeoutMs: 15 * 60 * 1e3,
19729
- env: { ...this.pythonSpawnEnv(), PATH: updatePath }
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
- `lop update stdout: ${run.stdout.trim() || "(empty)"}`,
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
- `lop update stderr: ${run.stderr.trim()}`,
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
- return await this.runGlobalUpdateAttempt(backend, plan, target2);
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
- if (!consolePath) {
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 before = this.readGlobalInstallVersion() ?? plan.installedInstallVersion;
19804
- this.sendToRenderer("backend-update-progress", { phase: "installing" });
19805
- writePendingServerUpdateMarker(this.markerDir(), {
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
- const run = await this.runGlobalUpdate(consolePath);
19811
- const after = this.readGlobalInstallVersion();
19812
- clearPendingServerUpdateMarker(this.markerDir());
19813
- const landed = run.exitCode === 0 && didUpgradeLand({ before, after, target: target2 });
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 running = await this.getInstalledBackendVersion();
19816
- const tail = stderrTail(run.stderr);
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
- `Global install update did not land (exited ${run.exitCode}, ran ${run.ran}): ${before ?? "unknown"} -> ${after ?? "unknown"}, running backend reports ${running ?? "no reading"}`,
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: `${!run.ran ? "The server update did not finish: the installer could not be run to a verdict or timed out." : run.exitCode !== 0 ? `The server update did not install: \`lop update\` exited ${run.exitCode}.` : `The server update to ${target2 ?? "the new release"} did not take effect: the install still reports ${after ?? before ?? "its previous version"}.`} The running backend is still serving${running ? ` version ${running}` : ""}; see the update service log for the installer's output.${tail ? `
19823
-
19824
- ${tail}` : ""}`,
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 running = this.eventRunningVersion(health);
20356
+ const running2 = this.eventRunningVersion(health);
19837
20357
  logger.info(
19838
- `Install moved to ${after ?? "unknown"}; the attached backend still reports ${running ?? "no reading"}${running === health ? "" : ` (its own record; /health answers ${health ?? "no reading"})`}`,
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: running,
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(target2);
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 after = this.readGlobalInstallVersion();
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(