local-operator-ui 0.28.2 → 0.28.4

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 (72) hide show
  1. package/out/main/index.js +620 -63
  2. package/out/renderer/assets/{_basePickBy-BiVlVvaz.js → _basePickBy-m0UwIwI-.js} +1 -1
  3. package/out/renderer/assets/{_baseUniq-B7hmLjHp.js → _baseUniq-B1e5WxRp.js} +1 -1
  4. package/out/renderer/assets/agent-details-page-DT2XjUkD.js +6 -0
  5. package/out/renderer/assets/agent-hub-page-CDkOhuwp.js +6 -0
  6. package/out/renderer/assets/{agents-page-DpmiwHFn.js → agents-page-CdW9L7GP.js} +2 -2
  7. package/out/renderer/assets/{arc-zQfKMj24.js → arc-CvNo9IfF.js} +1 -1
  8. package/out/renderer/assets/{architectureDiagram-IEHRJDOE-B7ZATeGZ.js → architectureDiagram-IEHRJDOE-b6gyJWBp.js} +1 -1
  9. package/out/renderer/assets/{blockDiagram-JOT3LUYC-CHlXcfDj.js → blockDiagram-JOT3LUYC-BagjA5Xe.js} +1 -1
  10. package/out/renderer/assets/browser-page-D7BKWCO0.js +1 -0
  11. package/out/renderer/assets/{c4Diagram-VJAJSXHY-Cqc7SCB2.js → c4Diagram-VJAJSXHY-DD_BZI1i.js} +1 -1
  12. package/out/renderer/assets/channel-CF2CxemR.js +1 -0
  13. package/out/renderer/assets/{chunk-4BMEZGHF-Bsl_7NtA.js → chunk-4BMEZGHF-CBD8EzOC.js} +1 -1
  14. package/out/renderer/assets/{chunk-A2AXSNBT-ggoGKGXQ.js → chunk-A2AXSNBT-CveWCVjO.js} +1 -1
  15. package/out/renderer/assets/{chunk-AEK57VVT-DqWWmU8P.js → chunk-AEK57VVT-C0vMuZFa.js} +1 -1
  16. package/out/renderer/assets/{chunk-D6G4REZN-BoMbjS6y.js → chunk-D6G4REZN-DzjTeFHj.js} +1 -1
  17. package/out/renderer/assets/{chunk-RZ5BOZE2-is_C2hBC.js → chunk-RZ5BOZE2-ZGCVflK1.js} +1 -1
  18. package/out/renderer/assets/{chunk-XZIHB7SX-ld86pApa.js → chunk-XZIHB7SX-DoQvlXVO.js} +1 -1
  19. package/out/renderer/assets/classDiagram-GIVACNV2-0fYfsmc-.js +1 -0
  20. package/out/renderer/assets/classDiagram-v2-COTLJTTW-0fYfsmc-.js +1 -0
  21. package/out/renderer/assets/clone-DAEsvOHv.js +1 -0
  22. package/out/renderer/assets/{compact-pagination-BSC2TmbH.js → compact-pagination-CJsjPK0e.js} +1 -1
  23. package/out/renderer/assets/{dagre-OKDRZEBW-32aJrB_O.js → dagre-OKDRZEBW-Kt7tHHXg.js} +1 -1
  24. package/out/renderer/assets/{diagram-SSKATNLV-Amr2xP2t.js → diagram-SSKATNLV-C95pxXOh.js} +1 -1
  25. package/out/renderer/assets/{diagram-VNBRO52H-Bc_-ILS5.js → diagram-VNBRO52H-DzNoiDve.js} +1 -1
  26. package/out/renderer/assets/{erDiagram-Q7BY3M3F-ZwSCjJ6e.js → erDiagram-Q7BY3M3F-D6pLsfhY.js} +1 -1
  27. package/out/renderer/assets/{flowDiagram-4HSFHLVR-Dql7kx-Z.js → flowDiagram-4HSFHLVR-CsYwACng.js} +1 -1
  28. package/out/renderer/assets/{ganttDiagram-APWFNJXF-DSWWoa_C.js → ganttDiagram-APWFNJXF-CnIGzOXa.js} +1 -1
  29. package/out/renderer/assets/{gitGraphDiagram-7IBYFJ6S-cbUhz4PY.js → gitGraphDiagram-7IBYFJ6S-DsCw2EgC.js} +1 -1
  30. package/out/renderer/assets/{graph-CojCeg8k.js → graph-D4YQS541.js} +1 -1
  31. package/out/renderer/assets/{icon-BJ1diQ13.js → icon-Bt4fnn3F.js} +1 -1
  32. package/out/renderer/assets/icon-DhYiAgZq.css +1 -0
  33. package/out/renderer/assets/{index-DgY41JzN.js → index-BNyd28pr.js} +1 -1
  34. package/out/renderer/assets/{index-DP42nWkP.js → index-CME8QOxA.js} +1 -1
  35. package/out/renderer/assets/{index-YYBGshw-.js → index-CSkqUKM-.js} +384 -374
  36. package/out/renderer/assets/{infoDiagram-PH2N3AL5-DCWE8TqR.js → infoDiagram-PH2N3AL5-DObO_6iZ.js} +1 -1
  37. package/out/renderer/assets/{installer-BH6UzMkw.js → installer-BWJdvQyC.js} +3 -3
  38. package/out/renderer/assets/{journeyDiagram-U35MCT3I-BVApHQpT.js → journeyDiagram-U35MCT3I-CtE5xgnZ.js} +1 -1
  39. package/out/renderer/assets/{kanban-definition-NDS4AKOZ-BHlQbvEk.js → kanban-definition-NDS4AKOZ-g5xy4MmB.js} +1 -1
  40. package/out/renderer/assets/{layout-C68weM2q.js → layout-ClrAgc3u.js} +1 -1
  41. package/out/renderer/assets/{legacy-agents-page-DySZFeGz.js → legacy-agents-page-BtejMok0.js} +3 -3
  42. package/out/renderer/assets/{mermaid.core-mZpI-6tM.js → mermaid.core-DvlHbnIM.js} +5 -5
  43. package/out/renderer/assets/{mindmap-definition-ALO5MXBD-CUBn2plb.js → mindmap-definition-ALO5MXBD-CwOcxWvn.js} +1 -1
  44. package/out/renderer/assets/{page-header-BkCnUAVS.js → page-header-DX9YUSyo.js} +1 -1
  45. package/out/renderer/assets/{parseISO-BUcHeIn4.js → parseISO-C3zww-5R.js} +1 -1
  46. package/out/renderer/assets/{pieDiagram-IB7DONF6-D9khOoXJ.js → pieDiagram-IB7DONF6-D0QyueG-.js} +1 -1
  47. package/out/renderer/assets/{quadrantDiagram-7GDLP6J5-Ckc_HLn6.js → quadrantDiagram-7GDLP6J5-c33oG3Jl.js} +1 -1
  48. package/out/renderer/assets/{radar-MK3ICKWK-E_AG5HZR.js → radar-MK3ICKWK-O1mRjAtb.js} +1 -1
  49. package/out/renderer/assets/{radient-auth-buttons-DWMBn27v.js → radient-auth-buttons-C6SOaDr-.js} +2 -2
  50. package/out/renderer/assets/{requirementDiagram-KVF5MWMF-D4z3bPKG.js → requirementDiagram-KVF5MWMF-BfCMVyv1.js} +1 -1
  51. package/out/renderer/assets/{sankeyDiagram-QLVOVGJD-CdqXkAPm.js → sankeyDiagram-QLVOVGJD-DLPDxWoI.js} +1 -1
  52. package/out/renderer/assets/{schedules-page-DKdkQ-bz.js → schedules-page-tTbriOVH.js} +1 -1
  53. package/out/renderer/assets/{sequenceDiagram-X6HHIX6F-cRXMF1u2.js → sequenceDiagram-X6HHIX6F-cScFWwXx.js} +1 -1
  54. package/out/renderer/assets/{settings-page-DxOCG1h9.js → settings-page-CUMzyo_U.js} +8 -8
  55. package/out/renderer/assets/{stateDiagram-DGXRK772-rwTz9XiH.js → stateDiagram-DGXRK772-DXtPvR30.js} +1 -1
  56. package/out/renderer/assets/stateDiagram-v2-YXO3MK2T-DU6SrBbV.js +1 -0
  57. package/out/renderer/assets/{timeline-definition-BDJGKUSR-DjpmacOZ.js → timeline-definition-BDJGKUSR-BNiecZOr.js} +1 -1
  58. package/out/renderer/assets/use-agent-like-mutation-Dp1XJuzk.js +1 -0
  59. package/out/renderer/assets/{xychartDiagram-VJFVF3MP-C-PscNet.js → xychartDiagram-VJFVF3MP-1FizMplM.js} +1 -1
  60. package/out/renderer/index.html +3 -3
  61. package/out/renderer/installer.html +3 -3
  62. package/package.json +2 -2
  63. package/out/renderer/assets/agent-details-page-Bo1iXiw2.js +0 -6
  64. package/out/renderer/assets/agent-hub-page-DpYsf3xz.js +0 -11
  65. package/out/renderer/assets/browser-page-XN0o_4Fc.js +0 -1
  66. package/out/renderer/assets/channel-DZNoINil.js +0 -1
  67. package/out/renderer/assets/classDiagram-GIVACNV2-CWYh2jD8.js +0 -1
  68. package/out/renderer/assets/classDiagram-v2-COTLJTTW-CWYh2jD8.js +0 -1
  69. package/out/renderer/assets/clone-3puJuEer.js +0 -1
  70. package/out/renderer/assets/icon-B9Dq7y-Q.css +0 -1
  71. package/out/renderer/assets/stateDiagram-v2-YXO3MK2T-DK-O9XV3.js +0 -1
  72. package/out/renderer/assets/use-agent-like-mutation-BgxlHPcf.js +0 -1
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
@@ -909,6 +941,13 @@ const desktopRequestSchema = zod.z.discriminatedUnion("op", [
909
941
  "agents.favourited",
910
942
  "agents.favourite_count",
911
943
  "agents.download_count",
944
+ /*
945
+ * The viewer's own like/favourite state for a whole page of agents. The
946
+ * closed vocabulary has to name it here as well as in
947
+ * `shared/api/radient/proxy.ts`, because this schema is what validates
948
+ * the request the renderer actually sends.
949
+ */
950
+ "agents.statuses",
912
951
  "comments.list",
913
952
  "comments.create",
914
953
  "comments.update",
@@ -992,6 +1031,7 @@ function desktopRequestDeadlineMs(op) {
992
1031
  return LONG_READ_OPS.has(op) ? DESKTOP_LONG_READ_DEADLINE_MS : DESKTOP_CONTROL_DEADLINE_MS;
993
1032
  }
994
1033
  const DESKTOP_DEADLINE_EXCEEDED_CODE = "deadline_exceeded";
1034
+ const DESKTOP_FOREGROUND_REQUIRED_MESSAGE = "View these completions in the foreground before marking them read.";
995
1035
  const READ_ONLY_OPS = /* @__PURE__ */ new Set([
996
1036
  "capabilities",
997
1037
  "accounts.list",
@@ -1234,6 +1274,25 @@ function desktopEndpoint(request) {
1234
1274
  method: "POST",
1235
1275
  body: { completion_token: request.completionToken }
1236
1276
  };
1277
+ case "attention.seen":
1278
+ return {
1279
+ /*
1280
+ * A path with no `{session_id}` in it, deliberately: the batch spans
1281
+ * sessions, and `/v1/desktop/attention/seen` cannot be shadowed by the
1282
+ * per-session route at any registration order. The body is snake_case
1283
+ * like every other route in that module, and the conversation identity
1284
+ * is NEVER taken from the client — the backend derives `session/<id>`
1285
+ * from the validated session id, the rule `/seen` already follows.
1286
+ */
1287
+ path: "/v1/desktop/attention/seen",
1288
+ method: "POST",
1289
+ body: {
1290
+ items: request.items.map((item) => ({
1291
+ session_id: item.sessionId,
1292
+ completion_token: item.completionToken
1293
+ }))
1294
+ }
1295
+ };
1237
1296
  case "sessions.notified":
1238
1297
  return {
1239
1298
  path: `/v1/desktop/sessions/${request.sessionId}/notified`,
@@ -2657,7 +2716,20 @@ function parsePendingServerUpdateMarker(raw) {
2657
2716
  return {
2658
2717
  before: typeof parsed.before === "string" ? parsed.before : null,
2659
2718
  target: typeof parsed.target === "string" ? parsed.target : null,
2660
- startedAt: parsed.startedAt
2719
+ startedAt: parsed.startedAt,
2720
+ // The three identity fields are additive: a record written before they
2721
+ // existed parses as "no evidence", which is exactly what the evaluator
2722
+ // bounds by the deadline rather than by a pid.
2723
+ deadlineAt: typeof parsed.deadlineAt === "string" ? parsed.deadlineAt : null,
2724
+ groupPid: typeof parsed.groupPid === "number" ? parsed.groupPid : null,
2725
+ groupStartedAt: typeof parsed.groupStartedAt === "string" ? parsed.groupStartedAt : null,
2726
+ /*
2727
+ * The install the attempt ran, and the reason it is parsed rather than dropped:
2728
+ * the reader has no press in scope, so the record is the ONLY thing that can tell
2729
+ * it which tree to ask about. A record without it is an older build's, and the
2730
+ * reader says so and falls back rather than guessing silently.
2731
+ */
2732
+ installPath: typeof parsed.installPath === "string" ? parsed.installPath : null
2661
2733
  };
2662
2734
  } catch {
2663
2735
  return null;
@@ -2678,6 +2750,29 @@ function clearPendingServerUpdateMarker(dir) {
2678
2750
  fs.rmSync(path2, { force: true });
2679
2751
  return true;
2680
2752
  }
2753
+ const PENDING_SERVER_UPDATE_GRACE_MS = 10 * 6e4;
2754
+ function evaluatePendingServerUpdateMarker(input) {
2755
+ const marker = input.marker;
2756
+ if (!marker) return { kind: "none" };
2757
+ if (marker.groupPid === null) {
2758
+ return { kind: "expired", marker, reason: "unproven" };
2759
+ }
2760
+ if (!input.groupAlive) {
2761
+ return { kind: "expired", marker, reason: "gone" };
2762
+ }
2763
+ const recorded = marker.groupStartedAt;
2764
+ const live = input.liveGroupStamp ?? null;
2765
+ if (recorded && live) {
2766
+ return recorded === live ? { kind: "running", marker, reason: "owned" } : { kind: "expired", marker, reason: "foreign-pid" };
2767
+ }
2768
+ const deadline2 = Date.parse(marker.deadlineAt ?? "");
2769
+ const now = input.now ?? Date.now();
2770
+ const grace = input.graceMs ?? PENDING_SERVER_UPDATE_GRACE_MS;
2771
+ if (Number.isFinite(deadline2) && now > deadline2 + grace) {
2772
+ return { kind: "expired", marker, reason: "expired" };
2773
+ }
2774
+ return { kind: "running", marker, reason: "unproven" };
2775
+ }
2681
2776
  const LAST_INSTALL_ATTEMPT_FILE = "last-update-install.json";
2682
2777
  function lastInstallAttemptPath(dir) {
2683
2778
  return path$1.join(dir, LAST_INSTALL_ATTEMPT_FILE);
@@ -3277,6 +3372,45 @@ function readSourceRef(prefix) {
3277
3372
  return null;
3278
3373
  }
3279
3374
  }
3375
+ function readSourceMarkerState(prefix) {
3376
+ if (!prefix) return { ref: null, mtimeMs: null };
3377
+ const marker = path$1.join(prefix, ".lop-source");
3378
+ try {
3379
+ const stat = fs.statSync(marker);
3380
+ return { ref: readSourceRef(prefix), mtimeMs: stat.mtimeMs };
3381
+ } catch {
3382
+ return { ref: null, mtimeMs: null };
3383
+ }
3384
+ }
3385
+ function didSourceRebuildLand(input) {
3386
+ const { before, after } = input;
3387
+ const hadMarker = before.ref !== null || before.mtimeMs !== null;
3388
+ const hasMarker = after.ref !== null || after.mtimeMs !== null;
3389
+ if (!hasMarker) return false;
3390
+ if (!hadMarker) return true;
3391
+ if (before.ref !== null && after.ref !== null) {
3392
+ return before.ref !== after.ref;
3393
+ }
3394
+ if (before.mtimeMs !== null && after.mtimeMs !== null) {
3395
+ return after.mtimeMs > before.mtimeMs;
3396
+ }
3397
+ return false;
3398
+ }
3399
+ const GUARD_BYPASS_ADVICE = /--skip-[a-z-]+|--allow-downgrade|--force\b|--no-verify|--ignore-checks/i;
3400
+ const TERMINAL_INSTRUCTION = /^(?!\S+:\s)(?:git|lop-update|uv|pip|pipx|python\d?|sudo)\b/i;
3401
+ function installDiagnosisLines(text, maxLines = 6) {
3402
+ const lines = text.split("\n").map((line) => line.trim()).filter((line) => line.length > 0);
3403
+ if (lines.length === 0) return "";
3404
+ const allowed = lines.filter((line) => !GUARD_BYPASS_ADVICE.test(line));
3405
+ if (allowed.length === 0) return "";
3406
+ const head = [];
3407
+ for (const line of allowed) {
3408
+ if (head.length >= maxLines) break;
3409
+ if (head.length > 0 && TERMINAL_INSTRUCTION.test(line)) break;
3410
+ head.push(line);
3411
+ }
3412
+ return head.join("\n").slice(0, 800);
3413
+ }
3280
3414
  const LOCAL_OPERATOR_DIST_INFO_VERSION = /^local[-_]operator[-_](.+)\.dist-info$/;
3281
3415
  function newerDistInfoVersion(candidate, current) {
3282
3416
  if (current === null) return true;
@@ -3501,8 +3635,14 @@ function classifyGlobalInstall(identity) {
3501
3635
  if (identity.installer === "pip") return "pip";
3502
3636
  return "global-unknown";
3503
3637
  }
3638
+ function installPrefixOf(identity) {
3639
+ if (identity.venvPrefix) return identity.venvPrefix;
3640
+ const script = identity.realPath ?? identity.path;
3641
+ return script ? path$1.dirname(path$1.dirname(script)) : null;
3642
+ }
3504
3643
  function resolveGlobalInstallPlan(input) {
3505
3644
  const kind = classifyGlobalInstall(input.identity);
3645
+ const installPrefix = installPrefixOf(input.identity);
3506
3646
  const sourceBuild = kind === "editable" || kind === "uv-tool" && isSourceBuildRef(input.identity.sourceRef);
3507
3647
  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
3648
  if (kind === "editable") {
@@ -3511,15 +3651,18 @@ function resolveGlobalInstallPlan(input) {
3511
3651
  updateCommand: "",
3512
3652
  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
3653
  detail,
3514
- sourceBuild: true
3654
+ sourceBuild: true,
3655
+ managedRoute: null,
3656
+ installPrefix
3515
3657
  };
3516
3658
  }
3517
3659
  if (kind === "uv-tool") {
3518
3660
  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." : "";
3661
+ const sourceRebuildRoute = !managed && sourceBuild && (input.sourceRebuild ?? null) !== null;
3662
+ const provenance = sourceBuild ? " source build of this machine's checkout; `lop update` would install the published release over it." : "";
3520
3663
  return {
3521
- canManageUpdate: managed,
3522
- updateCommand: "lop update",
3664
+ canManageUpdate: managed || sourceRebuildRoute,
3665
+ updateCommand: sourceRebuildRoute ? "lop-update" : "lop update",
3523
3666
  /*
3524
3667
  * The remedy is the sentence above the command well in the by-hand panel
3525
3668
  * and the consequence line in the managed offer, and each arm says the
@@ -3532,6 +3675,10 @@ function resolveGlobalInstallPlan(input) {
3532
3675
  * destructive thing this path does, so it is stated here rather than in
3533
3676
  * the in-flight panel the user reaches only after pressing.
3534
3677
  *
3678
+ * Source build - the same disclosure with the one difference that matters:
3679
+ * what is rebuilt is THEIR checkout, and the version they end up on is the
3680
+ * checkout's rather than the release the app offered.
3681
+ *
3535
3682
  * Legacy - WHY the app will not press the button, which is what the user
3536
3683
  * is choosing between (reviews U8, N1). It lived only in the mono Details
3537
3684
  * line, trailing a resolved path and a classification.
@@ -3540,9 +3687,11 @@ function resolveGlobalInstallPlan(input) {
3540
3687
  * panels, and in the by-hand panel the next line was the version sentence,
3541
3688
  * so the promise landed on the wrong line (review D5).
3542
3689
  */
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
3690
+ 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.",
3691
+ detail: `${detail}${managed ? provenance : sourceRebuildRoute ? " source build of this machine's checkout; an in-place rebuild." : provenance}`,
3692
+ sourceBuild,
3693
+ managedRoute: managed ? "entry-point" : sourceRebuildRoute ? "source-build" : null,
3694
+ installPrefix
3546
3695
  };
3547
3696
  }
3548
3697
  if (kind === "pipx") {
@@ -3551,7 +3700,9 @@ function resolveGlobalInstallPlan(input) {
3551
3700
  updateCommand: "pipx upgrade local-operator",
3552
3701
  remedy: "The server is a pipx install, so update it from your terminal",
3553
3702
  detail,
3554
- sourceBuild: false
3703
+ sourceBuild: false,
3704
+ managedRoute: null,
3705
+ installPrefix
3555
3706
  };
3556
3707
  }
3557
3708
  if (kind === "pip") {
@@ -3560,7 +3711,9 @@ function resolveGlobalInstallPlan(input) {
3560
3711
  updateCommand: "pip install --upgrade local-operator",
3561
3712
  remedy: "The server is a pip install, so update it from your terminal",
3562
3713
  detail,
3563
- sourceBuild: false
3714
+ sourceBuild: false,
3715
+ managedRoute: null,
3716
+ installPrefix
3564
3717
  };
3565
3718
  }
3566
3719
  return {
@@ -3568,7 +3721,9 @@ function resolveGlobalInstallPlan(input) {
3568
3721
  updateCommand: "",
3569
3722
  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
3723
  detail,
3571
- sourceBuild: false
3724
+ sourceBuild: false,
3725
+ managedRoute: null,
3726
+ installPrefix
3572
3727
  };
3573
3728
  }
3574
3729
  const PINNABLE_VERSION_REGEX = /^(?:\d+!)?\d+(?:\.\d+)*(?:(?:a|b|rc)\d+)?(?:\.post\d+)?(?:\.dev\d+)?(?:[-+][0-9A-Za-z.]+)?$/;
@@ -14982,16 +15137,16 @@ function createSessionCookieQuitHold(deps) {
14982
15137
  };
14983
15138
  }
14984
15139
  const OPERATION_ID = /^[a-zA-Z0-9_-]{1,128}$/;
15140
+ const FOREGROUND_RECEIPT_OPS = /* @__PURE__ */ new Set([
15141
+ "sessions.seen",
15142
+ "attention.seen"
15143
+ ]);
14985
15144
  function guardForegroundReceipts(window, send2) {
14986
15145
  return (input) => {
14987
- if (input !== null && typeof input === "object" && "op" in input && input.op === "sessions.seen") {
15146
+ if (input !== null && typeof input === "object" && "op" in input && typeof input.op === "string" && FOREGROUND_RECEIPT_OPS.has(input.op)) {
14988
15147
  const owner = window();
14989
15148
  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
- );
15149
+ return Promise.reject(new Error(DESKTOP_FOREGROUND_REQUIRED_MESSAGE));
14995
15150
  }
14996
15151
  }
14997
15152
  return send2(input);
@@ -16554,9 +16709,176 @@ function clauseTextOf(message, start, end) {
16554
16709
  function stripErrorPrefixes(message) {
16555
16710
  return message.replace(LEADING_ERROR_PREFIXES, "").replace(EMBEDDED_ERROR_PREFIX, ": ");
16556
16711
  }
16712
+ const GROUP_SIGNAL_GRACE_MS = 5e3;
16713
+ const GROUP_EXIT_WAIT_MS = 2e4;
16714
+ function isInstallGroupAlive(pid) {
16715
+ try {
16716
+ process.kill(-pid, 0);
16717
+ return true;
16718
+ } catch (error) {
16719
+ return error.code === "EPERM";
16720
+ }
16721
+ }
16722
+ function readProcessStartStamp(pid) {
16723
+ if (!Number.isInteger(pid) || pid <= 0) return null;
16724
+ try {
16725
+ const result = node_child_process.spawnSync("ps", ["-o", "lstart=", "-p", String(pid)], {
16726
+ encoding: "utf8",
16727
+ timeout: 2e3
16728
+ });
16729
+ if (result.status !== 0 || typeof result.stdout !== "string") return null;
16730
+ const stamp = result.stdout.trim();
16731
+ return stamp.length > 0 ? stamp : null;
16732
+ } catch {
16733
+ return null;
16734
+ }
16735
+ }
16736
+ function signalInstallGroup(pid, signal) {
16737
+ try {
16738
+ process.kill(-pid, signal);
16739
+ return true;
16740
+ } catch {
16741
+ return false;
16742
+ }
16743
+ }
16744
+ async function waitForInstallGroupGone(pid, timeoutMs = GROUP_EXIT_WAIT_MS) {
16745
+ const started = Date.now();
16746
+ while (Date.now() - started < timeoutMs) {
16747
+ if (!isInstallGroupAlive(pid)) return true;
16748
+ await new Promise((resolve) => setTimeout(resolve, 100));
16749
+ }
16750
+ return !isInstallGroupAlive(pid);
16751
+ }
16752
+ function runInOwnProcessGroup(input) {
16753
+ const graceMs = input.graceMs ?? GROUP_SIGNAL_GRACE_MS;
16754
+ const log = input.log ?? (() => {
16755
+ });
16756
+ return new Promise((resolve) => {
16757
+ let settled = false;
16758
+ let timedOut = false;
16759
+ let stdout = "";
16760
+ let stderr = "";
16761
+ let groupPid = null;
16762
+ let timer = null;
16763
+ let escalation = null;
16764
+ const finish = (result) => {
16765
+ if (settled) return;
16766
+ settled = true;
16767
+ if (timer) clearTimeout(timer);
16768
+ dropStream(child?.stdout);
16769
+ dropStream(child?.stderr);
16770
+ resolve({ ...result, groupPid });
16771
+ };
16772
+ let child = null;
16773
+ try {
16774
+ child = node_child_process.spawn(input.command, input.args, {
16775
+ detached: true,
16776
+ cwd: input.cwd,
16777
+ ...input.env ? { env: input.env } : {},
16778
+ // No inherited stdin: the front end is non-interactive, and an
16779
+ // inherited terminal on a GUI process is a handle to nothing.
16780
+ stdio: ["ignore", "pipe", "pipe"]
16781
+ });
16782
+ } catch (error) {
16783
+ log(
16784
+ `Could not start ${input.command}: ${error.message}`,
16785
+ "error"
16786
+ );
16787
+ finish({
16788
+ exitCode: null,
16789
+ stdout,
16790
+ stderr: error.message,
16791
+ ran: false,
16792
+ timedOut: false,
16793
+ groupStillRunning: false
16794
+ });
16795
+ return;
16796
+ }
16797
+ groupPid = child.pid ?? null;
16798
+ if (groupPid !== null) input.onSpawn?.(groupPid);
16799
+ const record = (chunk, stream) => {
16800
+ const text = String(chunk);
16801
+ if (stream === "stdout") {
16802
+ stdout = (stdout + text).slice(-4194304);
16803
+ } else {
16804
+ stderr = (stderr + text).slice(-4194304);
16805
+ }
16806
+ };
16807
+ child.stdout?.on("data", (chunk) => record(chunk, "stdout"));
16808
+ child.stderr?.on("data", (chunk) => record(chunk, "stderr"));
16809
+ child.on("error", (error) => {
16810
+ log(`The updater could not be run: ${error.message}`, "error");
16811
+ finish({
16812
+ exitCode: null,
16813
+ stdout,
16814
+ stderr: stderr.length > 0 ? stderr : error.message,
16815
+ ran: false,
16816
+ timedOut: false,
16817
+ groupStillRunning: false
16818
+ });
16819
+ });
16820
+ child.on("close", (code) => {
16821
+ finish({
16822
+ exitCode: code,
16823
+ stdout,
16824
+ stderr,
16825
+ ran: true,
16826
+ timedOut,
16827
+ groupStillRunning: false
16828
+ });
16829
+ });
16830
+ timer = setTimeout(() => {
16831
+ timedOut = true;
16832
+ log(
16833
+ `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`,
16834
+ "warn"
16835
+ );
16836
+ if (groupPid !== null) signalInstallGroup(groupPid, "SIGTERM");
16837
+ escalation = setTimeout(() => {
16838
+ if (groupPid === null || !isInstallGroupAlive(groupPid)) return;
16839
+ log(
16840
+ `The updater's process group was still alive ${graceMs}ms after the stop signal; killing it`,
16841
+ "warn"
16842
+ );
16843
+ signalInstallGroup(groupPid, "SIGKILL");
16844
+ }, graceMs);
16845
+ escalation.unref?.();
16846
+ finish({
16847
+ exitCode: null,
16848
+ stdout,
16849
+ stderr,
16850
+ ran: false,
16851
+ timedOut: true,
16852
+ groupStillRunning: groupPid !== null && isInstallGroupAlive(groupPid)
16853
+ });
16854
+ }, input.timeoutMs);
16855
+ });
16856
+ }
16857
+ function dropStream(stream) {
16858
+ const destroy = stream?.destroy;
16859
+ if (typeof destroy === "function") destroy.call(stream);
16860
+ }
16861
+ function serverUpdateFailureRemedy(input) {
16862
+ if (input.rebuildRoute && /REFUSING to release a stale ref/i.test(input.diagnosis)) {
16863
+ 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.";
16864
+ }
16865
+ if (input.rebuildRoute) {
16866
+ return "A rebuild changes this install only once it succeeds, so nothing was restarted.";
16867
+ }
16868
+ return "Nothing was restarted: the build that was serving is the build still serving.";
16869
+ }
16870
+ function serverUpdateFailureSentence(input) {
16871
+ 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"}.`;
16872
+ const stopped = input.groupSurvived ? " The updater was stopped; something it started may still be replacing the install." : "";
16873
+ const pointer = input.diagnosis ? " The installer's own output is below." : " The installer's own output is in the update service log.";
16874
+ const escapeHatch = input.rebuildRoute ? ` You can also run \`${input.updateCommand || "lop-update"}\` yourself in a terminal.` : "";
16875
+ return `${headline}${stopped} ${serverUpdateFailureRemedy(input)}${pointer}${escapeHatch}`;
16876
+ }
16557
16877
  const VERSION_LINE_REGEX = /Version:\s*([^\n]+)/;
16558
16878
  const VERSION_CLEAN_REGEX = /^v/i;
16559
16879
  const BETA_VERSION_REGEX = /v\d+\.\d+\.\d+\.beta\.\d+/;
16880
+ const GLOBAL_UPDATE_TIMEOUT_MS = 15 * 60 * 1e3;
16881
+ const SOURCE_REBUILD_TIMEOUT_MS = 30 * 60 * 1e3;
16560
16882
  const BUNDLE_ID_REGEX = /^[\w.-]+$/;
16561
16883
  const INSTALL_IN_FLIGHT_RECHECK_MS = 1e4;
16562
16884
  const LAUNCHCTL_NOT_LOADED_REGEX = /could not find|no such process/i;
@@ -16641,10 +16963,6 @@ const DRIFT_ABSENCE_REPORT = {
16641
16963
  "running-ahead": "the server is running a build NEWER than the install on disk (a downgrade on disk), so it is left alone",
16642
16964
  unorderable: "the two readings cannot be ordered against each other, so nothing is restarted on a guess"
16643
16965
  };
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
16966
  function readingOfHealth(health) {
16649
16967
  const result = health.result;
16650
16968
  return {
@@ -18814,9 +19132,22 @@ ${heal.removed.join("\n")}`,
18814
19132
  * machines this check exists for. Null when the prefix carries no console
18815
19133
  * script, which is "we could not classify it" rather than a guess.
18816
19134
  */
18817
- servingInstallIdentity(prefix) {
19135
+ /**
19136
+ * The console script an install root exposes, or null when it has none.
19137
+ *
19138
+ * ONE PLACE, because two callers must agree: the identity the app CLASSIFIES an
19139
+ * install by, and the command it RUNS for that install. They used to be derived
19140
+ * separately - the check read `<prefix>/bin/local-operator` while the press ran
19141
+ * whatever the shim resolved to - which is how the two halves came to describe
19142
+ * different installs (review round 5, Q-1 = M2).
19143
+ */
19144
+ servingConsoleScript(prefix) {
18818
19145
  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;
19146
+ return fs.existsSync(script) ? script : null;
19147
+ }
19148
+ servingInstallIdentity(prefix) {
19149
+ const script = this.servingConsoleScript(prefix);
19150
+ return script === null ? null : readInstallIdentity(script);
18820
19151
  }
18821
19152
  /**
18822
19153
  * Decide what the backend update prompt may offer for a startup mode.
@@ -18848,6 +19179,15 @@ ${heal.removed.join("\n")}`,
18848
19179
  return {
18849
19180
  canManageUpdate: false,
18850
19181
  updateCommand: "",
19182
+ /*
19183
+ * NO ROUTE AND NO PREFIX: this arm names no installer, which is the whole point
19184
+ * of the sentence above - there is nothing here for the app to run, so the
19185
+ * managed-route fields are empty rather than pointing at the managed tree.
19186
+ * (This arm is upstream's, arrived in the fold; the fields are this branch's,
19187
+ * and the type is what required them to be stated.)
19188
+ */
19189
+ managedRoute: null,
19190
+ installPrefix: null,
18851
19191
  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
19192
  detail,
18853
19193
  sourceBuild: false,
@@ -18858,7 +19198,10 @@ ${heal.removed.join("\n")}`,
18858
19198
  }
18859
19199
  const servingIdentity = serving.prefix !== null ? this.servingInstallIdentity(serving.prefix) : null;
18860
19200
  const identity = servingIdentity ?? await this.resolveInstallIdentity();
18861
- const plan = resolveGlobalInstallPlan({ identity });
19201
+ const plan = resolveGlobalInstallPlan({
19202
+ identity,
19203
+ sourceRebuild: resolveCommandPath("lop-update")
19204
+ });
18862
19205
  logger.info(
18863
19206
  `External backend install (${startupMode}): ${plan.detail}; remedy is \`${plan.updateCommand || "nothing the app can name"}\`; install version ${serving.version ?? identity.version ?? "unreadable"}`,
18864
19207
  LogFileType.UPDATE_SERVICE
@@ -18875,7 +19218,9 @@ ${heal.removed.join("\n")}`,
18875
19218
  * Either reading of the serving root proves the subject: its dist-info's
18876
19219
  * version, or the identity read from its own console script.
18877
19220
  */
18878
- installedInstallVersionDescribesServingInstall: serving.version !== null || servingIdentity !== null
19221
+ installedInstallVersionDescribesServingInstall: serving.version !== null || servingIdentity !== null,
19222
+ managedRoute: plan.managedRoute,
19223
+ installPrefix: plan.installPrefix
18879
19224
  };
18880
19225
  }
18881
19226
  return {
@@ -18913,9 +19258,25 @@ ${heal.removed.join("\n")}`,
18913
19258
  * construction - it is read FROM the root the server named - so it describes the
18914
19259
  * subject whenever there is a reading at all.
18915
19260
  */
18916
- installedInstallVersionDescribesServingInstall: serving.version !== null
19261
+ installedInstallVersionDescribesServingInstall: serving.version !== null,
19262
+ managedRoute: "entry-point",
19263
+ installPrefix: null
18917
19264
  };
18918
19265
  }
19266
+ /**
19267
+ * `.lop-source` for the install currently resolved, read from a fresh resolution.
19268
+ *
19269
+ * Re-resolved on every call rather than captured with the plan, for the reason
19270
+ * this whole path re-reads its evidence: between a check and a click the install
19271
+ * can move (a generations swap re-points `current`, and `lop-update` rewrites the
19272
+ * uv tool root), so a prefix held from minutes ago can name a tree nothing is
19273
+ * using any more. The reading is what `didSourceRebuildLand` compares, in both
19274
+ * directions.
19275
+ */
19276
+ readRebuildMarkerState(installPath) {
19277
+ const identity = readInstallIdentity(installPath);
19278
+ return readSourceMarkerState(identity.venvPrefix ?? null);
19279
+ }
18919
19280
  /**
18920
19281
  * Path the `local-operator` shim resolves to, or null when it is not installed.
18921
19282
  *
@@ -19684,15 +20045,24 @@ ${heal.removed.join("\n")}`,
19684
20045
  }
19685
20046
  /**
19686
20047
  * The version the resolved global install reports on disk, or null.
20048
+ * The version an install reports on disk, or null.
20049
+ *
20050
+ * Shell-free, and deliberately a different read from the running backend's `/health`:
20051
+ * the install is what an update moves, and immediately after one it is the only reading
20052
+ * that has moved - the daemon lags until it is restarted.
19687
20053
  *
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.
20054
+ * THE INSTALL IS A REQUIRED ARGUMENT, and that is a fix rather than a style. This read
20055
+ * used to default to `resolveLocalOperatorPath()` - the shim - and that default is how
20056
+ * the same seam kept arriving: a caller that forgot which install its attempt had run
20057
+ * silently read the shim instead, and four review rounds found it one site at a time
20058
+ * (the press against the check, the two evidence reads, then the unattended
20059
+ * reconciliation). A required argument turns that mistake into a compile error rather
20060
+ * than into a plausible number. The one caller with a record and no path - the
20061
+ * reconciliation reading a marker an older build wrote - names its fallback explicitly
20062
+ * and logs it.
19693
20063
  */
19694
- readGlobalInstallVersion() {
19695
- return readInstallIdentity(this.resolveLocalOperatorPath())?.version ?? null;
20064
+ readInstallVersionAt(installPath) {
20065
+ return readInstallIdentity(installPath)?.version ?? null;
19696
20066
  }
19697
20067
  /**
19698
20068
  * Run the install's own front end: `<resolved shim> update`.
@@ -19718,23 +20088,36 @@ ${heal.removed.join("\n")}`,
19718
20088
  * cannot land inside the sealed bundle. `shell: true` is never used - this is a
19719
20089
  * resolved shim path, not a command line.
19720
20090
  */
19721
- async runGlobalUpdate(consolePath) {
20091
+ async runGlobalUpdate(command, timeoutMs, onSpawn) {
19722
20092
  const updatePath = installerSearchPath(process.env, os.homedir());
19723
20093
  logger.info(
19724
- `Executing the install's own updater: ${consolePath} update (PATH: ${updatePath})`,
20094
+ `Executing the install's own updater: ${command.path} ${command.args.join(" ")} (PATH: ${updatePath})`,
19725
20095
  LogFileType.UPDATE_SERVICE
19726
20096
  );
19727
- const run = await runCommand(consolePath, ["update"], {
19728
- timeoutMs: 15 * 60 * 1e3,
19729
- env: { ...this.pythonSpawnEnv(), PATH: updatePath }
20097
+ const run = await runInOwnProcessGroup({
20098
+ command: command.path,
20099
+ args: command.args,
20100
+ timeoutMs,
20101
+ // A neutral working directory, never the app's resources dir, so an updater
20102
+ // that resolves a relative path from where it stands cannot land inside the
20103
+ // sealed bundle.
20104
+ cwd: os.homedir(),
20105
+ env: { ...this.pythonSpawnEnv(), PATH: updatePath },
20106
+ onSpawn,
20107
+ log: (line, level) => {
20108
+ if (level === "error") logger.error(line, LogFileType.UPDATE_SERVICE);
20109
+ else if (level === "warn")
20110
+ logger.warn(line, LogFileType.UPDATE_SERVICE);
20111
+ else logger.info(line, LogFileType.UPDATE_SERVICE);
20112
+ }
19730
20113
  });
19731
20114
  logger.info(
19732
- `lop update stdout: ${run.stdout.trim() || "(empty)"}`,
20115
+ `${command.path} stdout: ${run.stdout.trim() || "(empty)"}`,
19733
20116
  LogFileType.UPDATE_SERVICE
19734
20117
  );
19735
20118
  if (run.stderr.trim().length > 0) {
19736
20119
  logger.warn(
19737
- `lop update stderr: ${run.stderr.trim()}`,
20120
+ `${command.path} stderr: ${run.stderr.trim()}`,
19738
20121
  LogFileType.UPDATE_SERVICE
19739
20122
  );
19740
20123
  }
@@ -19770,6 +20153,36 @@ ${heal.removed.join("\n")}`,
19770
20153
  * exactly as the pip path does (`didUpgradeLand`).
19771
20154
  */
19772
20155
  async updateGlobalInstall(backend, plan, target2) {
20156
+ const recorded = readPendingServerUpdateMarker(this.markerDir());
20157
+ const recordedPid = recorded?.groupPid ?? null;
20158
+ const recordedGroupAlive = recordedPid !== null && isInstallGroupAlive(recordedPid);
20159
+ const outcome = evaluatePendingServerUpdateMarker({
20160
+ marker: recorded,
20161
+ groupAlive: recordedGroupAlive,
20162
+ liveGroupStamp: recordedGroupAlive && recordedPid !== null ? readProcessStartStamp(recordedPid) : null
20163
+ });
20164
+ if (outcome.kind === "expired") {
20165
+ logger.warn(
20166
+ `A recorded server update (${outcome.marker.startedAt}) is not running any more (${outcome.reason}); clearing the record so this attempt can proceed`,
20167
+ LogFileType.UPDATE_SERVICE
20168
+ );
20169
+ clearPendingServerUpdateMarker(this.markerDir());
20170
+ }
20171
+ if (outcome.kind === "running") {
20172
+ logger.warn(
20173
+ `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`,
20174
+ LogFileType.UPDATE_SERVICE
20175
+ );
20176
+ const startedAtMs = Date.parse(outcome.marker.startedAt);
20177
+ const ageMinutes = Number.isFinite(startedAtMs) ? Math.max(0, Math.round((Date.now() - startedAtMs) / 6e4)) : null;
20178
+ const age = ageMinutes === null ? "at a time that could not be read" : ageMinutes < 1 ? "moments ago" : `${ageMinutes} minute${ageMinutes === 1 ? "" : "s"} ago`;
20179
+ this.sendToRenderer("backend-update-error", {
20180
+ 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.`,
20181
+ phase: "update",
20182
+ logPath: serverUpdateLogPath()
20183
+ });
20184
+ return false;
20185
+ }
19773
20186
  if (this.serverUpdateInFlight) {
19774
20187
  logger.warn(
19775
20188
  "A global install update is already running; refusing to start a second beside it",
@@ -19783,16 +20196,75 @@ ${heal.removed.join("\n")}`,
19783
20196
  return false;
19784
20197
  }
19785
20198
  this.serverUpdateInFlight = true;
20199
+ let handedOff = false;
19786
20200
  try {
19787
- return await this.runGlobalUpdateAttempt(backend, plan, target2);
20201
+ const landed = await this.runGlobalUpdateAttempt(backend, plan, target2);
20202
+ const stillRunning = this.recordedUpdateStillRunning();
20203
+ if (stillRunning !== null) {
20204
+ handedOff = true;
20205
+ void this.holdGlobalInstallGuardUntilGroupGone(stillRunning);
20206
+ }
20207
+ return landed;
19788
20208
  } finally {
19789
- this.serverUpdateInFlight = false;
20209
+ if (!handedOff) this.serverUpdateInFlight = false;
19790
20210
  }
19791
20211
  }
20212
+ /**
20213
+ * The group a record on disk still names, or null when nothing is running.
20214
+ *
20215
+ * Read rather than passed, because it is the SAME evidence the next launch reads:
20216
+ * a guard that decided from an in-memory value would disagree with the launch
20217
+ * that follows a crash, which is the whole class this pass is closing.
20218
+ */
20219
+ recordedUpdateStillRunning() {
20220
+ const recorded = readPendingServerUpdateMarker(this.markerDir());
20221
+ const pid = recorded?.groupPid ?? null;
20222
+ if (pid === null) return null;
20223
+ const alive = isInstallGroupAlive(pid);
20224
+ const outcome = evaluatePendingServerUpdateMarker({
20225
+ marker: recorded,
20226
+ groupAlive: alive,
20227
+ liveGroupStamp: alive ? readProcessStartStamp(pid) : null
20228
+ });
20229
+ return outcome.kind === "running" ? pid : null;
20230
+ }
20231
+ /**
20232
+ * Hold the install guard until a signalled group is really gone.
20233
+ *
20234
+ * Fire-and-forget on purpose: the verdict has already been delivered to the user,
20235
+ * and awaiting this would put a descendant that outlived its stop signal back in
20236
+ * the panel's path. The record is cleared only once the group is gone, because
20237
+ * until then it is the evidence that an updater is still writing into the install
20238
+ * root - the thing another launch has to see.
20239
+ */
20240
+ async holdGlobalInstallGuardUntilGroupGone(groupPid) {
20241
+ let gone = await waitForInstallGroupGone(groupPid, GROUP_EXIT_WAIT_MS);
20242
+ while (!gone) {
20243
+ logger.warn(
20244
+ `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`,
20245
+ LogFileType.UPDATE_SERVICE
20246
+ );
20247
+ await new Promise((resolve) => setTimeout(resolve, 5e3));
20248
+ gone = !isInstallGroupAlive(groupPid);
20249
+ }
20250
+ clearPendingServerUpdateMarker(this.markerDir());
20251
+ this.serverUpdateInFlight = false;
20252
+ }
19792
20253
  /** The attempt itself, under the guard above. */
19793
20254
  async runGlobalUpdateAttempt(backend, plan, target2) {
19794
20255
  const consolePath = this.resolveLocalOperatorPath();
19795
- if (!consolePath) {
20256
+ const rebuildPath = resolveCommandPath("lop-update");
20257
+ const running = await this.readRunningBackend();
20258
+ const serving = this.readServingInstall(running);
20259
+ const servingScript = serving.prefix ? this.servingConsoleScript(serving.prefix) : null;
20260
+ const servingIdentity = serving.prefix ? this.servingInstallIdentity(serving.prefix) : null;
20261
+ const installPath = servingScript ?? consolePath;
20262
+ const freshPlan = resolveGlobalInstallPlan({
20263
+ identity: servingIdentity ?? readInstallIdentity(installPath),
20264
+ sourceRebuild: rebuildPath
20265
+ });
20266
+ const rebuildRoute = freshPlan.managedRoute === "source-build" && rebuildPath !== null;
20267
+ if (!consolePath && !rebuildRoute) {
19796
20268
  this.sendToRenderer("backend-update-error", {
19797
20269
  message: "The global install could not be found on this machine, so the update did not run. See the update service log.",
19798
20270
  phase: "update",
@@ -19800,28 +20272,83 @@ ${heal.removed.join("\n")}`,
19800
20272
  });
19801
20273
  return false;
19802
20274
  }
19803
- const before = this.readGlobalInstallVersion() ?? plan.installedInstallVersion;
19804
- this.sendToRenderer("backend-update-progress", { phase: "installing" });
19805
- writePendingServerUpdateMarker(this.markerDir(), {
20275
+ const budgetMs = rebuildRoute ? SOURCE_REBUILD_TIMEOUT_MS : GLOBAL_UPDATE_TIMEOUT_MS;
20276
+ const before = this.readInstallVersionAt(installPath) ?? plan.installedInstallVersion;
20277
+ const markerBefore = rebuildRoute ? this.readRebuildMarkerState(installPath) : null;
20278
+ this.sendToRenderer("backend-update-progress", {
20279
+ phase: "installing",
20280
+ sourceRebuild: rebuildRoute
20281
+ });
20282
+ const marker = {
19806
20283
  before,
19807
20284
  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 });
20285
+ startedAt: (/* @__PURE__ */ new Date()).toISOString(),
20286
+ // The record's own budget, so a reader can tell a run that is going from a
20287
+ // record that outlived it without consulting a clock the app chose later.
20288
+ deadlineAt: new Date(Date.now() + budgetMs).toISOString(),
20289
+ groupPid: null,
20290
+ groupStartedAt: null,
20291
+ /*
20292
+ * The install this attempt runs, recorded so a later launch does not have to resolve
20293
+ * one of its own: the reconciliation reads this, and a marker without it (an older
20294
+ * build's) is the only case that falls back.
20295
+ */
20296
+ installPath
20297
+ };
20298
+ writePendingServerUpdateMarker(this.markerDir(), marker);
20299
+ const run = await this.runGlobalUpdate(
20300
+ rebuildRoute && rebuildPath !== null ? { path: rebuildPath, args: [] } : (
20301
+ /*
20302
+ * THE SERVING INSTALL'S OWN FRONT END when it has one, and the shim's
20303
+ * resolution only as the fallback: the identity above is that script's, so
20304
+ * running anything else would update one install and verify another - the
20305
+ * seam this case classifies (review round 5, Q-1 = M2).
20306
+ */
20307
+ { path: installPath ?? "", args: ["update"] }
20308
+ ),
20309
+ budgetMs,
20310
+ (pid) => {
20311
+ writePendingServerUpdateMarker(this.markerDir(), {
20312
+ before,
20313
+ target: target2,
20314
+ startedAt: marker.startedAt,
20315
+ deadlineAt: marker.deadlineAt,
20316
+ groupPid: pid,
20317
+ groupStartedAt: readProcessStartStamp(pid),
20318
+ installPath
20319
+ });
20320
+ }
20321
+ );
20322
+ const after = this.readInstallVersionAt(installPath);
20323
+ const markerAfter = rebuildRoute ? this.readRebuildMarkerState(installPath) : null;
20324
+ const groupSurvived = run.timedOut && run.groupStillRunning && run.groupPid !== null;
20325
+ if (!groupSurvived) clearPendingServerUpdateMarker(this.markerDir());
20326
+ const landed = run.exitCode === 0 && (rebuildRoute ? didSourceRebuildLand({
20327
+ before: markerBefore ?? { ref: null, mtimeMs: null },
20328
+ after: markerAfter ?? { ref: null, mtimeMs: null }
20329
+ }) : didUpgradeLand({ before, after, target: target2 }));
19814
20330
  if (!landed) {
19815
- const running = await this.getInstalledBackendVersion();
19816
- const tail = stderrTail(run.stderr);
20331
+ const running2 = await this.getInstalledBackendVersion();
20332
+ const diagnosis = installDiagnosisLines(
20333
+ run.stderr.length > 0 ? run.stderr : run.stdout
20334
+ );
19817
20335
  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"}`,
20336
+ `${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
20337
  LogFileType.UPDATE_SERVICE
19820
20338
  );
19821
20339
  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}` : ""}`,
20340
+ message: serverUpdateFailureSentence({
20341
+ rebuildRoute,
20342
+ ran: run.ran,
20343
+ exitCode: run.exitCode,
20344
+ groupSurvived,
20345
+ diagnosis,
20346
+ target: target2,
20347
+ after,
20348
+ before,
20349
+ updateCommand: freshPlan.updateCommand ?? null
20350
+ }),
20351
+ installerOutput: diagnosis || void 0,
19825
20352
  phase: "update",
19826
20353
  logPath: serverUpdateLogPath()
19827
20354
  });
@@ -19833,14 +20360,14 @@ ${tail}` : ""}`,
19833
20360
  );
19834
20361
  if (backend.isUsingExternalBackend()) {
19835
20362
  const health = await this.getInstalledBackendVersion();
19836
- const running = this.eventRunningVersion(health);
20363
+ const running2 = this.eventRunningVersion(health);
19837
20364
  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"})`}`,
20365
+ `Install moved to ${after ?? "unknown"}; the attached backend still reports ${running2 ?? "no reading"}${running2 === health ? "" : ` (its own record; /health answers ${health ?? "no reading"})`}`,
19839
20366
  LogFileType.UPDATE_SERVICE
19840
20367
  );
19841
20368
  this.sendToRenderer("backend-update-completed", {
19842
20369
  installVersion: after,
19843
- runningVersion: running,
20370
+ runningVersion: running2,
19844
20371
  restarted: false,
19845
20372
  restartable: this.backendIsAppOwned(),
19846
20373
  /*
@@ -19888,7 +20415,14 @@ ${tail}` : ""}`,
19888
20415
  return false;
19889
20416
  }
19890
20417
  }
19891
- const reported = await this.waitForBackendVersion(target2);
20418
+ const reported = await this.waitForBackendVersion(
20419
+ // The rebuild keeps the version, so there is no target to hold it to: the
20420
+ // version the restarted daemon reports is the CHECKOUT's, and holding it to
20421
+ // the release the app offered would fail a restart that landed (the same
20422
+ // reasoning as `sourceBuild` in the prompt). What is held is that the daemon
20423
+ // is back and answering at all.
20424
+ rebuildRoute ? null : target2
20425
+ );
19892
20426
  if (reported === null) {
19893
20427
  logger.error(
19894
20428
  `Backend restarted onto the updated install but did not report ${target2 ?? "the target version"}`,
@@ -19931,7 +20465,30 @@ ${tail}` : ""}`,
19931
20465
  async reportUnattendedServerUpdate() {
19932
20466
  const marker = readPendingServerUpdateMarker(this.markerDir());
19933
20467
  if (!marker) return;
19934
- const after = this.readGlobalInstallVersion();
20468
+ const recordedPid = marker.groupPid ?? null;
20469
+ const recordedAlive = recordedPid !== null && isInstallGroupAlive(recordedPid);
20470
+ const outcome = evaluatePendingServerUpdateMarker({
20471
+ marker,
20472
+ groupAlive: recordedAlive,
20473
+ liveGroupStamp: recordedAlive && recordedPid !== null ? readProcessStartStamp(recordedPid) : null
20474
+ });
20475
+ if (outcome.kind === "running") {
20476
+ logger.warn(
20477
+ `A server update recorded at ${marker.startedAt} is still running (group ${recordedPid}, ${outcome.reason}); leaving its record in place`,
20478
+ LogFileType.UPDATE_SERVICE
20479
+ );
20480
+ return;
20481
+ }
20482
+ const recordPath = marker.installPath ?? null;
20483
+ const after = this.readInstallVersionAt(
20484
+ recordPath ?? this.resolveLocalOperatorPath()
20485
+ );
20486
+ if (recordPath === null) {
20487
+ logger.warn(
20488
+ `The update record from ${marker.startedAt} names no install (written by an older build); reading the shim's resolution instead`,
20489
+ LogFileType.UPDATE_SERVICE
20490
+ );
20491
+ }
19935
20492
  clearPendingServerUpdateMarker(this.markerDir());
19936
20493
  if (!after || after === marker.before) {
19937
20494
  logger.info(