local-operator-ui 0.26.2 → 0.26.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.
- package/out/main/index.js +934 -134
- package/out/preload/index.js +8 -1
- package/out/renderer/assets/{_basePickBy-Yh6ElFVE.js → _basePickBy-CMDvPx06.js} +1 -1
- package/out/renderer/assets/{_baseUniq-o6svF9UW.js → _baseUniq-CyTFSDKr.js} +1 -1
- package/out/renderer/assets/{agent-details-page-BkC2lX7r.js → agent-details-page-kZMf8MHK.js} +1 -1
- package/out/renderer/assets/{agent-hub-page-DUxDs-Kn.js → agent-hub-page-B0e-lhRQ.js} +1 -1
- package/out/renderer/assets/{agents-page-DAebxFRb.js → agents-page-Dbfv_f5H.js} +2 -2
- package/out/renderer/assets/{arc-C3lR8mfR.js → arc-lqRc3qif.js} +1 -1
- package/out/renderer/assets/{architectureDiagram-IEHRJDOE-C645_jS6.js → architectureDiagram-IEHRJDOE-C2_O8el6.js} +1 -1
- package/out/renderer/assets/{blockDiagram-JOT3LUYC-Du9DQ6FQ.js → blockDiagram-JOT3LUYC-DocM5IRU.js} +1 -1
- package/out/renderer/assets/browser-page-CP5lJ9uY.js +1 -0
- package/out/renderer/assets/{c4Diagram-VJAJSXHY-DpM_PJYK.js → c4Diagram-VJAJSXHY-DQWGIBdr.js} +1 -1
- package/out/renderer/assets/channel-Dr9HGzNK.js +1 -0
- package/out/renderer/assets/{chunk-4BMEZGHF-CmWFwhfL.js → chunk-4BMEZGHF-BxmzjOZh.js} +1 -1
- package/out/renderer/assets/{chunk-A2AXSNBT-CpUzW8wB.js → chunk-A2AXSNBT-NpShlg8c.js} +1 -1
- package/out/renderer/assets/{chunk-AEK57VVT-CLlsGZV5.js → chunk-AEK57VVT-B0TZSkz7.js} +1 -1
- package/out/renderer/assets/{chunk-D6G4REZN-CQJonAXh.js → chunk-D6G4REZN-CXRQFY6k.js} +1 -1
- package/out/renderer/assets/{chunk-RZ5BOZE2-CKVoS-gF.js → chunk-RZ5BOZE2-Cmu9Ik6u.js} +1 -1
- package/out/renderer/assets/{chunk-XZIHB7SX-B6793RIq.js → chunk-XZIHB7SX-BT3Mo2NV.js} +1 -1
- package/out/renderer/assets/classDiagram-GIVACNV2-CTprNpRM.js +1 -0
- package/out/renderer/assets/classDiagram-v2-COTLJTTW-CTprNpRM.js +1 -0
- package/out/renderer/assets/clone-DDcwKS67.js +1 -0
- package/out/renderer/assets/{compact-pagination-T1jmplV0.js → compact-pagination-D01Rvfur.js} +1 -1
- package/out/renderer/assets/{dagre-OKDRZEBW-BCz3Jhej.js → dagre-OKDRZEBW-UQnLr4NW.js} +1 -1
- package/out/renderer/assets/{diagram-SSKATNLV-DBvre51R.js → diagram-SSKATNLV-3w1dMlg3.js} +1 -1
- package/out/renderer/assets/{diagram-VNBRO52H-DR3_-QrB.js → diagram-VNBRO52H-CCZaWhVp.js} +1 -1
- package/out/renderer/assets/{erDiagram-Q7BY3M3F-C5WKRf1i.js → erDiagram-Q7BY3M3F-uZMQmX7_.js} +1 -1
- package/out/renderer/assets/{flowDiagram-4HSFHLVR-BDr6xFe-.js → flowDiagram-4HSFHLVR-BKourybF.js} +1 -1
- package/out/renderer/assets/{ganttDiagram-APWFNJXF-2Q5OUp3Q.js → ganttDiagram-APWFNJXF-BhYXEmQA.js} +1 -1
- package/out/renderer/assets/{gitGraphDiagram-7IBYFJ6S-CS96w_AJ.js → gitGraphDiagram-7IBYFJ6S-D_m6vlq6.js} +1 -1
- package/out/renderer/assets/{graph-CydyUtTs.js → graph-Dq_3CFha.js} +1 -1
- package/out/renderer/assets/icon-0tEvAhQU.css +1 -0
- package/out/renderer/assets/index-B1X0vjjH.css +1 -0
- package/out/renderer/assets/{index-CiNiUEhN.js → index-B5_r62an.js} +1 -1
- package/out/renderer/assets/{index-3dOhEzqL.js → index-BOpDgAjv.js} +379 -372
- package/out/renderer/assets/{index-B67rWyNs.js → index-CNyA8Ppl.js} +1 -1
- package/out/renderer/assets/{infoDiagram-PH2N3AL5-BluJPlfV.js → infoDiagram-PH2N3AL5-BaWnC0fK.js} +1 -1
- package/out/renderer/assets/{installer-DDldqUI5.js → installer-EGSglW9O.js} +1 -1
- package/out/renderer/assets/{journeyDiagram-U35MCT3I-BXzCbMiD.js → journeyDiagram-U35MCT3I-CExc8Lvh.js} +1 -1
- package/out/renderer/assets/{kanban-definition-NDS4AKOZ-CWnEWdT3.js → kanban-definition-NDS4AKOZ-k2dTO3gs.js} +1 -1
- package/out/renderer/assets/{layout-BZxn8yaA.js → layout-DsQh3awQ.js} +1 -1
- package/out/renderer/assets/{legacy-agents-page-CBN2d2Km.js → legacy-agents-page-6eRuLUCJ.js} +1 -1
- package/out/renderer/assets/{mermaid.core-CkgQHyLv.js → mermaid.core-Cc73RCEu.js} +5 -5
- package/out/renderer/assets/{mindmap-definition-ALO5MXBD-CMdsYtR2.js → mindmap-definition-ALO5MXBD-CY1b7QnZ.js} +1 -1
- package/out/renderer/assets/{page-header-un3csrkZ.js → page-header-Cg5Oasi6.js} +1 -1
- package/out/renderer/assets/{parseISO-B4A0wn_M.js → parseISO-xitnFuBA.js} +1 -1
- package/out/renderer/assets/{pieDiagram-IB7DONF6--Ch74RbA.js → pieDiagram-IB7DONF6-CnJiQspi.js} +1 -1
- package/out/renderer/assets/{quadrantDiagram-7GDLP6J5-BhMZpv_m.js → quadrantDiagram-7GDLP6J5-DlkqVGNi.js} +1 -1
- package/out/renderer/assets/{radar-MK3ICKWK-CQVUegyJ.js → radar-MK3ICKWK-Dei_MUJO.js} +1 -1
- package/out/renderer/assets/{radient-auth-buttons-kol7Tscy.js → radient-auth-buttons-DyHyMHQM.js} +1 -1
- package/out/renderer/assets/{requirementDiagram-KVF5MWMF-C1VBqnM2.js → requirementDiagram-KVF5MWMF-Dqo5gkes.js} +1 -1
- package/out/renderer/assets/{sankeyDiagram-QLVOVGJD-7HYTv-zO.js → sankeyDiagram-QLVOVGJD-By-NBmQd.js} +1 -1
- package/out/renderer/assets/schedules-page-CkK-8qjn.js +120 -0
- package/out/renderer/assets/{sequenceDiagram-X6HHIX6F-BD0nIQXN.js → sequenceDiagram-X6HHIX6F-WYj_m0ij.js} +1 -1
- package/out/renderer/assets/{settings-page-BujhgJa0.js → settings-page-ONfhyYmU.js} +1 -1
- package/out/renderer/assets/{stateDiagram-DGXRK772-BPHghgWL.js → stateDiagram-DGXRK772-1_7lAHAU.js} +1 -1
- package/out/renderer/assets/stateDiagram-v2-YXO3MK2T-DXjoneIR.js +1 -0
- package/out/renderer/assets/{timeline-definition-BDJGKUSR-qjwFHKyY.js → timeline-definition-BDJGKUSR-B7q4bXZF.js} +1 -1
- package/out/renderer/assets/{use-agent-like-mutation-9tqsIjhh.js → use-agent-like-mutation-ZT3Ct6fx.js} +1 -1
- package/out/renderer/assets/{xychartDiagram-VJFVF3MP-BaPrSrI7.js → xychartDiagram-VJFVF3MP-TTQCkrx5.js} +1 -1
- package/out/renderer/index.html +4 -4
- package/out/renderer/installer.html +3 -3
- package/package.json +2 -2
- package/out/renderer/assets/browser-page-D_Iq6NfX.js +0 -1
- package/out/renderer/assets/channel-BT1OyuPi.js +0 -1
- package/out/renderer/assets/classDiagram-GIVACNV2-BxGKz_N_.js +0 -1
- package/out/renderer/assets/classDiagram-v2-COTLJTTW-BxGKz_N_.js +0 -1
- package/out/renderer/assets/clone-BjcUGFxf.js +0 -1
- package/out/renderer/assets/icon-B8Li1YYS.css +0 -1
- package/out/renderer/assets/index-aW0Bo_1a.css +0 -1
- package/out/renderer/assets/schedules-page-BHVkJmVD.js +0 -115
- package/out/renderer/assets/stateDiagram-v2-YXO3MK2T-D6Z_BxtC.js +0 -1
- /package/out/renderer/assets/{icon-Bny6j4NZ.js → icon--RqZR_iT.js} +0 -0
package/out/main/index.js
CHANGED
|
@@ -99,6 +99,9 @@ const variableKey = zod.z.string().min(1).max(128).refine(
|
|
|
99
99
|
).refine(isAddressableVariableKey);
|
|
100
100
|
const variableType = zod.z.enum(["str", "int", "float", "bool", "list", "dict"]);
|
|
101
101
|
const variableValue = zod.z.string().max(16384);
|
|
102
|
+
const WAKE_MESSAGE_MAX_CHARS = 2e3;
|
|
103
|
+
const wakeMessage = zod.z.string().min(1).max(WAKE_MESSAGE_MAX_CHARS);
|
|
104
|
+
const wakeId = zod.z.string().min(1).max(64);
|
|
102
105
|
const scheduleWrite = zod.z.object({
|
|
103
106
|
is_active: zod.z.boolean().nullish(),
|
|
104
107
|
one_time: zod.z.boolean().nullish(),
|
|
@@ -620,6 +623,91 @@ const desktopRequestSchema = zod.z.discriminatedUnion("op", [
|
|
|
620
623
|
sessionId,
|
|
621
624
|
asideId: requestId
|
|
622
625
|
}).strict(),
|
|
626
|
+
/*
|
|
627
|
+
* The wake surface: the Schedules page's machine-wide read and its writes.
|
|
628
|
+
*
|
|
629
|
+
* Deliberately NOT an extension of `sessions.*`. The session list is
|
|
630
|
+
* paginated by recency (`limit ≤ 500`), so a session armed once and never
|
|
631
|
+
* opened falls off it - and the page whose whole job is "every session that
|
|
632
|
+
* has wakes" would then list fewer sessions than exist. `wakes.list` reads
|
|
633
|
+
* the wake index instead, which is one small JSON file per wake-carrying
|
|
634
|
+
* session and is complete at any store size.
|
|
635
|
+
*
|
|
636
|
+
* The four ops sit behind the same router-level desktop bearer as their
|
|
637
|
+
* `sessions.*` siblings, and every one of them does its filesystem work off
|
|
638
|
+
* the event loop, so a listing over a cold store cannot stall the desktop
|
|
639
|
+
* plane's other readers.
|
|
640
|
+
*/
|
|
641
|
+
zod.z.object({
|
|
642
|
+
op: zod.z.literal("wakes.list"),
|
|
643
|
+
/* Bounded like `sessions.list`: the field exists so a pathological
|
|
644
|
+
store degrades visibly (through `truncated`) rather than silently. */
|
|
645
|
+
limit: zod.z.number().int().min(1).max(500).optional(),
|
|
646
|
+
/* Dormant rows are the ones whose session was stopped, which the page
|
|
647
|
+
SHOWS ("parked") rather than hides: stopping a conversation parks its
|
|
648
|
+
wakes, and a management surface that dropped them would report a
|
|
649
|
+
scheduled task as gone when it is only parked. */
|
|
650
|
+
includeDormant: zod.z.boolean().optional()
|
|
651
|
+
}).strict(),
|
|
652
|
+
zod.z.object({
|
|
653
|
+
op: zod.z.literal("wakes.create"),
|
|
654
|
+
requestId,
|
|
655
|
+
/*
|
|
656
|
+
* The session half of the request is EXACTLY ONE of two shapes: name an
|
|
657
|
+
* existing `sessionId`, or name a `cwd` (plus an optional `target`) and
|
|
658
|
+
* have the backend create the conversation and arm the wake in one
|
|
659
|
+
* call. The exclusivity cannot be stated with a `.refine()` on this
|
|
660
|
+
* object - that turns the member into a `ZodEffects`, which a
|
|
661
|
+
* discriminated union cannot take (see `mcp.credentials.store`, which
|
|
662
|
+
* records the same trap) - so the API client enforces it on the way out
|
|
663
|
+
* with `wakeCreateBody`, and the backend refuses the malformed shape
|
|
664
|
+
* with a 422 rather than a 500.
|
|
665
|
+
*/
|
|
666
|
+
sessionId: sessionId.optional(),
|
|
667
|
+
cwd: zod.z.string().min(1).max(4096).optional(),
|
|
668
|
+
target: target.optional(),
|
|
669
|
+
message: wakeMessage,
|
|
670
|
+
/*
|
|
671
|
+
* Timing, in the same spellings the wake tool and `lop wake create`
|
|
672
|
+
* take, parsed by the backend's own `parse_wake_duration` /
|
|
673
|
+
* `parse_wake_at`: a relative duration (`30m`), an ISO instant or the
|
|
674
|
+
* next `HH:MM`. The dialog's presets are `in`/`at` pairs so the common
|
|
675
|
+
* path never touches a calendar, and `Pick a time…` sends the ISO
|
|
676
|
+
* instant the picker yields.
|
|
677
|
+
*/
|
|
678
|
+
in: zod.z.string().min(1).max(64).optional(),
|
|
679
|
+
at: zod.z.string().min(1).max(64).optional(),
|
|
680
|
+
every: zod.z.string().min(1).max(64).optional(),
|
|
681
|
+
until: zod.z.string().min(1).max(64).optional(),
|
|
682
|
+
limit: zod.z.number().int().min(1).optional(),
|
|
683
|
+
/*
|
|
684
|
+
* The conversation's own title, when a caller wants to override the one
|
|
685
|
+
* derived from the prompt.
|
|
686
|
+
*
|
|
687
|
+
* Carried because the interface declares it, and never set by this
|
|
688
|
+
* page: the prompt IS the name here (the backend writes it into the
|
|
689
|
+
* session's stored-title sidecar, which `resume.session_name` consults
|
|
690
|
+
* first), which is what keeps the create flow free of a "name" field
|
|
691
|
+
* nobody would fill in.
|
|
692
|
+
*/
|
|
693
|
+
title: zod.z.string().min(1).max(200).optional()
|
|
694
|
+
}).strict(),
|
|
695
|
+
zod.z.object({
|
|
696
|
+
op: zod.z.literal("wakes.edit"),
|
|
697
|
+
sessionId,
|
|
698
|
+
wakeId,
|
|
699
|
+
/* Every field optional, and the body is sent with the absent ones
|
|
700
|
+
OMITTED rather than nulled: the backend applies the update against
|
|
701
|
+
the schedule it holds, so a `null` here would be a request to clear
|
|
702
|
+
a bound the user did not touch. */
|
|
703
|
+
message: wakeMessage.optional(),
|
|
704
|
+
in: zod.z.string().min(1).max(64).optional(),
|
|
705
|
+
at: zod.z.string().min(1).max(64).optional(),
|
|
706
|
+
every: zod.z.string().min(1).max(64).optional(),
|
|
707
|
+
until: zod.z.string().min(1).max(64).optional(),
|
|
708
|
+
limit: zod.z.number().int().min(1).optional()
|
|
709
|
+
}).strict(),
|
|
710
|
+
zod.z.object({ op: zod.z.literal("wakes.remove"), sessionId, wakeId }).strict(),
|
|
623
711
|
zod.z.object({ op: zod.z.literal("mcp.list"), sessionId }).strict(),
|
|
624
712
|
zod.z.object({
|
|
625
713
|
op: zod.z.literal("mcp.credentials.store"),
|
|
@@ -762,6 +850,82 @@ function desktopRequestByteBudget(op) {
|
|
|
762
850
|
return DESKTOP_SYSTEM_PROMPT_BYTE_BUDGET;
|
|
763
851
|
return MESSAGE_OPS.has(op) ? DESKTOP_MESSAGE_BYTE_BUDGET : DESKTOP_CONTROL_BYTE_BUDGET;
|
|
764
852
|
}
|
|
853
|
+
const DESKTOP_CONTROL_DEADLINE_MS = 2e4;
|
|
854
|
+
const DESKTOP_LONG_READ_DEADLINE_MS = 9e4;
|
|
855
|
+
const LEDGER_READ_OPS = /* @__PURE__ */ new Set([
|
|
856
|
+
"analytics.get",
|
|
857
|
+
"sessions.report"
|
|
858
|
+
]);
|
|
859
|
+
const PROVIDER_READ_OPS = /* @__PURE__ */ new Set(["usage.get"]);
|
|
860
|
+
const LONG_READ_OPS = /* @__PURE__ */ new Set([
|
|
861
|
+
...LEDGER_READ_OPS,
|
|
862
|
+
...PROVIDER_READ_OPS
|
|
863
|
+
]);
|
|
864
|
+
function desktopRequestDeadlineMs(op) {
|
|
865
|
+
return LONG_READ_OPS.has(op) ? DESKTOP_LONG_READ_DEADLINE_MS : DESKTOP_CONTROL_DEADLINE_MS;
|
|
866
|
+
}
|
|
867
|
+
const DESKTOP_DEADLINE_EXCEEDED_CODE = "deadline_exceeded";
|
|
868
|
+
const READ_ONLY_OPS = /* @__PURE__ */ new Set([
|
|
869
|
+
"capabilities",
|
|
870
|
+
"accounts.list",
|
|
871
|
+
"analytics.get",
|
|
872
|
+
"commands.entities",
|
|
873
|
+
"commands.list",
|
|
874
|
+
"config.get",
|
|
875
|
+
"credentials.list",
|
|
876
|
+
"info.get",
|
|
877
|
+
"instructions.get",
|
|
878
|
+
"legacy.agent.get",
|
|
879
|
+
"legacy.agent.history",
|
|
880
|
+
"legacy.agent.schedules.list",
|
|
881
|
+
"legacy.agents.list",
|
|
882
|
+
"legacy.job.get",
|
|
883
|
+
"legacy.jobs.list",
|
|
884
|
+
"legacy.models",
|
|
885
|
+
"legacy.models.providers",
|
|
886
|
+
"legacy.schedule.get",
|
|
887
|
+
"legacy.schedules.list",
|
|
888
|
+
"mcp.list",
|
|
889
|
+
"models.catalogue",
|
|
890
|
+
"profiles.get",
|
|
891
|
+
"profiles.list",
|
|
892
|
+
"providers.list",
|
|
893
|
+
"sessions.aside.get",
|
|
894
|
+
"sessions.failovers",
|
|
895
|
+
"sessions.get",
|
|
896
|
+
"sessions.history",
|
|
897
|
+
"sessions.list",
|
|
898
|
+
"sessions.preview",
|
|
899
|
+
"sessions.report",
|
|
900
|
+
"sessions.search",
|
|
901
|
+
"sessions.variables.list",
|
|
902
|
+
"skills.list",
|
|
903
|
+
"subagents.transcript",
|
|
904
|
+
"teams.get",
|
|
905
|
+
"teams.list",
|
|
906
|
+
"usage.get"
|
|
907
|
+
]);
|
|
908
|
+
const PANEL_READ_OPS = /* @__PURE__ */ new Set([
|
|
909
|
+
"analytics.get",
|
|
910
|
+
"usage.get",
|
|
911
|
+
"sessions.report",
|
|
912
|
+
"info.get",
|
|
913
|
+
"sessions.failovers"
|
|
914
|
+
]);
|
|
915
|
+
function desktopRequestDeadlineDetail(op, deadlineMs) {
|
|
916
|
+
const seconds = Math.round(deadlineMs / 1e3);
|
|
917
|
+
const code = DESKTOP_DEADLINE_EXCEEDED_CODE;
|
|
918
|
+
if (READ_ONLY_OPS.has(op)) {
|
|
919
|
+
return {
|
|
920
|
+
code,
|
|
921
|
+
message: PANEL_READ_OPS.has(op) ? `The app waits up to ${seconds} seconds for this panel's data, and the read was still running when the app stopped waiting. Nothing was read; reopen the panel to ask again.` : `The app waits up to ${seconds} seconds for this read, and it was still running when the app stopped waiting. Nothing was read; ask again.`
|
|
922
|
+
};
|
|
923
|
+
}
|
|
924
|
+
return {
|
|
925
|
+
code,
|
|
926
|
+
message: `The app waits up to ${seconds} seconds for this request, and it was still running when the app stopped waiting. It may or may not have reached the server; check the result before repeating it.`
|
|
927
|
+
};
|
|
928
|
+
}
|
|
765
929
|
const DESKTOP_REQUEST_TOO_LARGE_DETAIL = "This message is too large to send in one request. Remove an image, or split the text across two messages.";
|
|
766
930
|
function desktopRequestTooLargeDetail(op) {
|
|
767
931
|
if (op === "legacy.agent.systemPrompt.update")
|
|
@@ -773,6 +937,22 @@ function desktopRequestTooLargeDetail(op) {
|
|
|
773
937
|
if (MESSAGE_OPS.has(op)) return DESKTOP_REQUEST_TOO_LARGE_DETAIL;
|
|
774
938
|
return "This request is too large to send. Shorten the text in this form.";
|
|
775
939
|
}
|
|
940
|
+
function wakeSessionHalf(request) {
|
|
941
|
+
if (request.sessionId) return { session_id: request.sessionId };
|
|
942
|
+
return {
|
|
943
|
+
...request.cwd ? { cwd: request.cwd } : {},
|
|
944
|
+
...request.target ? { target: request.target } : {}
|
|
945
|
+
};
|
|
946
|
+
}
|
|
947
|
+
function wakeTiming(request) {
|
|
948
|
+
return {
|
|
949
|
+
...request.in !== void 0 ? { in: request.in } : {},
|
|
950
|
+
...request.at !== void 0 ? { at: request.at } : {},
|
|
951
|
+
...request.every !== void 0 ? { every: request.every } : {},
|
|
952
|
+
...request.until !== void 0 ? { until: request.until } : {},
|
|
953
|
+
...request.limit !== void 0 ? { limit: request.limit } : {}
|
|
954
|
+
};
|
|
955
|
+
}
|
|
776
956
|
function desktopEndpoint(request) {
|
|
777
957
|
switch (request.op) {
|
|
778
958
|
case "capabilities":
|
|
@@ -1061,6 +1241,47 @@ function desktopEndpoint(request) {
|
|
|
1061
1241
|
method: "GET"
|
|
1062
1242
|
};
|
|
1063
1243
|
}
|
|
1244
|
+
/*
|
|
1245
|
+
* The wake routes, mapped beside their `sessions.*` siblings: one op per
|
|
1246
|
+
* method, snake_case on the wire (`include_dormant`, `request_id`), which is
|
|
1247
|
+
* what the backend's models declare.
|
|
1248
|
+
*/
|
|
1249
|
+
case "wakes.list": {
|
|
1250
|
+
const query = new URLSearchParams();
|
|
1251
|
+
if (request.limit !== void 0)
|
|
1252
|
+
query.set("limit", String(request.limit));
|
|
1253
|
+
if (request.includeDormant !== void 0)
|
|
1254
|
+
query.set("include_dormant", String(request.includeDormant));
|
|
1255
|
+
return {
|
|
1256
|
+
path: query.size > 0 ? `/v1/desktop/wakes?${query}` : "/v1/desktop/wakes",
|
|
1257
|
+
method: "GET"
|
|
1258
|
+
};
|
|
1259
|
+
}
|
|
1260
|
+
case "wakes.create":
|
|
1261
|
+
return {
|
|
1262
|
+
path: "/v1/desktop/wakes",
|
|
1263
|
+
method: "POST",
|
|
1264
|
+
body: {
|
|
1265
|
+
request_id: request.requestId,
|
|
1266
|
+
...wakeSessionHalf(request),
|
|
1267
|
+
message: request.message,
|
|
1268
|
+
...wakeTiming(request)
|
|
1269
|
+
}
|
|
1270
|
+
};
|
|
1271
|
+
case "wakes.edit":
|
|
1272
|
+
return {
|
|
1273
|
+
path: `/v1/desktop/wakes/${request.sessionId}/${request.wakeId}`,
|
|
1274
|
+
method: "PATCH",
|
|
1275
|
+
body: {
|
|
1276
|
+
...request.message !== void 0 ? { message: request.message } : {},
|
|
1277
|
+
...wakeTiming(request)
|
|
1278
|
+
}
|
|
1279
|
+
};
|
|
1280
|
+
case "wakes.remove":
|
|
1281
|
+
return {
|
|
1282
|
+
path: `/v1/desktop/wakes/${request.sessionId}/${request.wakeId}`,
|
|
1283
|
+
method: "DELETE"
|
|
1284
|
+
};
|
|
1064
1285
|
case "legacy.jobs.list": {
|
|
1065
1286
|
const query = new URLSearchParams();
|
|
1066
1287
|
if (request.agentId) query.set("agent_id", request.agentId);
|
|
@@ -2107,6 +2328,7 @@ async function requestDesktopOutcome(input, backendUrl, token) {
|
|
|
2107
2328
|
}
|
|
2108
2329
|
const target2 = desktopEndpoint(request);
|
|
2109
2330
|
let answered = false;
|
|
2331
|
+
const deadlineMs = desktopRequestDeadlineMs(request.op);
|
|
2110
2332
|
try {
|
|
2111
2333
|
const body = target2.body === void 0 ? void 0 : JSON.stringify(target2.body);
|
|
2112
2334
|
if (body && Buffer.byteLength(body) > desktopRequestByteBudget(request.op)) {
|
|
@@ -2127,7 +2349,7 @@ async function requestDesktopOutcome(input, backendUrl, token) {
|
|
|
2127
2349
|
},
|
|
2128
2350
|
body,
|
|
2129
2351
|
redirect: "error",
|
|
2130
|
-
signal: AbortSignal.timeout(
|
|
2352
|
+
signal: AbortSignal.timeout(deadlineMs)
|
|
2131
2353
|
});
|
|
2132
2354
|
answered = true;
|
|
2133
2355
|
const result = response.status === 204 ? null : await response.json();
|
|
@@ -2138,7 +2360,24 @@ async function requestDesktopOutcome(input, backendUrl, token) {
|
|
|
2138
2360
|
response: { status: response.status, body: result },
|
|
2139
2361
|
answered: true
|
|
2140
2362
|
};
|
|
2141
|
-
} catch {
|
|
2363
|
+
} catch (error) {
|
|
2364
|
+
if (deadlineExceeded(error)) {
|
|
2365
|
+
return {
|
|
2366
|
+
response: {
|
|
2367
|
+
status: 504,
|
|
2368
|
+
body: {
|
|
2369
|
+
detail: desktopRequestDeadlineDetail(request.op, deadlineMs)
|
|
2370
|
+
}
|
|
2371
|
+
},
|
|
2372
|
+
/*
|
|
2373
|
+
* Carried through the same way as the 503 below, and it means the same
|
|
2374
|
+
* thing here: an abort during the body read is an answer this process
|
|
2375
|
+
* received and could not finish, which is still liveness evidence, while
|
|
2376
|
+
* an abort that never got a response is not.
|
|
2377
|
+
*/
|
|
2378
|
+
answered
|
|
2379
|
+
};
|
|
2380
|
+
}
|
|
2142
2381
|
return {
|
|
2143
2382
|
response: {
|
|
2144
2383
|
status: 503,
|
|
@@ -2165,6 +2404,10 @@ async function requestDesktopOutcome(input, backendUrl, token) {
|
|
|
2165
2404
|
async function requestDesktop(input, backendUrl, token) {
|
|
2166
2405
|
return (await requestDesktopOutcome(input, backendUrl, token)).response;
|
|
2167
2406
|
}
|
|
2407
|
+
function deadlineExceeded(error) {
|
|
2408
|
+
const name = error?.name;
|
|
2409
|
+
return name === "TimeoutError" || name === "AbortError";
|
|
2410
|
+
}
|
|
2168
2411
|
function trustedDesktopFrame(actual, expected) {
|
|
2169
2412
|
try {
|
|
2170
2413
|
const current = new URL(actual);
|
|
@@ -2637,6 +2880,49 @@ function clearPendingInstallMarker(dir) {
|
|
|
2637
2880
|
fs.rmSync(path2, { force: true });
|
|
2638
2881
|
return true;
|
|
2639
2882
|
}
|
|
2883
|
+
const PENDING_SERVER_UPDATE_MARKER_FILE = "pending-server-update.json";
|
|
2884
|
+
function pendingServerUpdateMarkerPath(dir) {
|
|
2885
|
+
return path$1.join(dir, PENDING_SERVER_UPDATE_MARKER_FILE);
|
|
2886
|
+
}
|
|
2887
|
+
function writePendingServerUpdateMarker(dir, marker) {
|
|
2888
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
2889
|
+
const target2 = pendingServerUpdateMarkerPath(dir);
|
|
2890
|
+
const temp = `${target2}.tmp`;
|
|
2891
|
+
fs.writeFileSync(temp, `${JSON.stringify(marker, null, 2)}
|
|
2892
|
+
`, "utf8");
|
|
2893
|
+
fs.renameSync(temp, target2);
|
|
2894
|
+
return marker;
|
|
2895
|
+
}
|
|
2896
|
+
function parsePendingServerUpdateMarker(raw) {
|
|
2897
|
+
try {
|
|
2898
|
+
const parsed = JSON.parse(raw);
|
|
2899
|
+
if (typeof parsed?.startedAt !== "string" || parsed.startedAt.length === 0) {
|
|
2900
|
+
return null;
|
|
2901
|
+
}
|
|
2902
|
+
return {
|
|
2903
|
+
before: typeof parsed.before === "string" ? parsed.before : null,
|
|
2904
|
+
target: typeof parsed.target === "string" ? parsed.target : null,
|
|
2905
|
+
startedAt: parsed.startedAt
|
|
2906
|
+
};
|
|
2907
|
+
} catch {
|
|
2908
|
+
return null;
|
|
2909
|
+
}
|
|
2910
|
+
}
|
|
2911
|
+
function readPendingServerUpdateMarker(dir) {
|
|
2912
|
+
const path2 = pendingServerUpdateMarkerPath(dir);
|
|
2913
|
+
if (!fs.existsSync(path2)) return null;
|
|
2914
|
+
try {
|
|
2915
|
+
return parsePendingServerUpdateMarker(fs.readFileSync(path2, "utf8"));
|
|
2916
|
+
} catch {
|
|
2917
|
+
return null;
|
|
2918
|
+
}
|
|
2919
|
+
}
|
|
2920
|
+
function clearPendingServerUpdateMarker(dir) {
|
|
2921
|
+
const path2 = pendingServerUpdateMarkerPath(dir);
|
|
2922
|
+
if (!fs.existsSync(path2)) return false;
|
|
2923
|
+
fs.rmSync(path2, { force: true });
|
|
2924
|
+
return true;
|
|
2925
|
+
}
|
|
2640
2926
|
const LAST_INSTALL_ATTEMPT_FILE = "last-update-install.json";
|
|
2641
2927
|
function lastInstallAttemptPath(dir) {
|
|
2642
2928
|
return path$1.join(dir, LAST_INSTALL_ATTEMPT_FILE);
|
|
@@ -3216,6 +3502,33 @@ function pipxListNamesLocalOperator(output) {
|
|
|
3216
3502
|
}
|
|
3217
3503
|
const PYTHON_LIB_DIR = /^python\d/;
|
|
3218
3504
|
const LOCAL_OPERATOR_DIST_INFO = /^local[-_]operator[-_]/;
|
|
3505
|
+
const PYPI_SOURCE_TOKEN = "pypi";
|
|
3506
|
+
const SOURCE_MARKER_SEPARATOR = /\s+/;
|
|
3507
|
+
function isSourceBuildRef(token) {
|
|
3508
|
+
if (typeof token !== "string") return false;
|
|
3509
|
+
const trimmed = token.trim();
|
|
3510
|
+
if (trimmed.length < 7 || trimmed.length > 40) return false;
|
|
3511
|
+
for (const char of trimmed) {
|
|
3512
|
+
if (!"0123456789abcdefABCDEF".includes(char)) return false;
|
|
3513
|
+
}
|
|
3514
|
+
return trimmed.toLowerCase() !== PYPI_SOURCE_TOKEN;
|
|
3515
|
+
}
|
|
3516
|
+
function readSourceRef(prefix) {
|
|
3517
|
+
try {
|
|
3518
|
+
const raw = fs.readFileSync(path$1.join(prefix, ".lop-source"), "utf8");
|
|
3519
|
+
const token = raw.split(SOURCE_MARKER_SEPARATOR).filter(Boolean)[0];
|
|
3520
|
+
return token && token.length > 0 ? token : null;
|
|
3521
|
+
} catch {
|
|
3522
|
+
return null;
|
|
3523
|
+
}
|
|
3524
|
+
}
|
|
3525
|
+
const LOCAL_OPERATOR_DIST_INFO_VERSION = /^local[-_]operator[-_](.+)\.dist-info$/;
|
|
3526
|
+
function newerDistInfoVersion(candidate, current) {
|
|
3527
|
+
if (current === null) return true;
|
|
3528
|
+
const order = compareVersions$1(candidate, current);
|
|
3529
|
+
if (order !== null) return order > 0;
|
|
3530
|
+
return compareVersions$1(current, candidate) === null && candidate > current;
|
|
3531
|
+
}
|
|
3219
3532
|
function resolveDistributionMarkers(prefix) {
|
|
3220
3533
|
const sitePackages = [];
|
|
3221
3534
|
try {
|
|
@@ -3230,6 +3543,7 @@ function resolveDistributionMarkers(prefix) {
|
|
|
3230
3543
|
if (fs.existsSync(windowsSitePackages)) sitePackages.push(windowsSitePackages);
|
|
3231
3544
|
let installer = null;
|
|
3232
3545
|
let editable = false;
|
|
3546
|
+
let version = null;
|
|
3233
3547
|
for (const dir of sitePackages) {
|
|
3234
3548
|
let entries;
|
|
3235
3549
|
try {
|
|
@@ -3241,6 +3555,8 @@ function resolveDistributionMarkers(prefix) {
|
|
|
3241
3555
|
if (!LOCAL_OPERATOR_DIST_INFO.test(entry)) continue;
|
|
3242
3556
|
if (!entry.endsWith(".dist-info")) continue;
|
|
3243
3557
|
const distInfo = path$1.join(dir, entry);
|
|
3558
|
+
const parsed = LOCAL_OPERATOR_DIST_INFO_VERSION.exec(entry)?.[1]?.trim();
|
|
3559
|
+
if (parsed && newerDistInfoVersion(parsed, version)) version = parsed;
|
|
3244
3560
|
try {
|
|
3245
3561
|
const installerText = fs.readFileSync(path$1.join(distInfo, "INSTALLER"), "utf8");
|
|
3246
3562
|
installer = installerText.trim().toLowerCase() || installer;
|
|
@@ -3257,7 +3573,7 @@ function resolveDistributionMarkers(prefix) {
|
|
|
3257
3573
|
}
|
|
3258
3574
|
}
|
|
3259
3575
|
}
|
|
3260
|
-
return { installer, editable };
|
|
3576
|
+
return { installer, editable, version, sourceRef: readSourceRef(prefix) };
|
|
3261
3577
|
}
|
|
3262
3578
|
const TRAILING_SLASHES = /\/+$/;
|
|
3263
3579
|
function commandSearchDirs(env, home) {
|
|
@@ -3279,6 +3595,14 @@ function commandSearchDirs(env, home) {
|
|
|
3279
3595
|
add("/bin");
|
|
3280
3596
|
return dirs;
|
|
3281
3597
|
}
|
|
3598
|
+
function installerSearchPath(env = process.env, home = os.homedir()) {
|
|
3599
|
+
const inherited = (env.PATH ?? "").split(path$1.delimiter).map((dir) => dir.replace(TRAILING_SLASHES, "")).filter((dir) => dir.length > 0);
|
|
3600
|
+
const inheritedSet = new Set(inherited);
|
|
3601
|
+
const ours = commandSearchDirs(env, home).filter(
|
|
3602
|
+
(dir) => !inheritedSet.has(dir)
|
|
3603
|
+
);
|
|
3604
|
+
return [...ours, ...inherited].join(path$1.delimiter);
|
|
3605
|
+
}
|
|
3282
3606
|
function uvToolBinDirs(env, home, listDir, platform) {
|
|
3283
3607
|
const root = env.UV_TOOL_DIR ?? (env.XDG_DATA_HOME ? path$1.join(env.XDG_DATA_HOME, "uv", "tools") : path$1.join(home, ".local", "share", "uv", "tools"));
|
|
3284
3608
|
const scriptDir = platform === "win32" ? "Scripts" : "bin";
|
|
@@ -3337,6 +3661,23 @@ function resolveCommandPath(name, options = {}) {
|
|
|
3337
3661
|
}
|
|
3338
3662
|
return null;
|
|
3339
3663
|
}
|
|
3664
|
+
function resolveGlobalConsoleScript(options = {}) {
|
|
3665
|
+
if ((options.platform ?? process.platform) === "win32") {
|
|
3666
|
+
const first = (name) => {
|
|
3667
|
+
try {
|
|
3668
|
+
const resolved = node_child_process.execFileSync("where", [name], {
|
|
3669
|
+
encoding: "utf8",
|
|
3670
|
+
timeout: 5e3
|
|
3671
|
+
}).split("\n")[0]?.trim();
|
|
3672
|
+
return resolved && resolved.length > 0 ? resolved : null;
|
|
3673
|
+
} catch {
|
|
3674
|
+
return null;
|
|
3675
|
+
}
|
|
3676
|
+
};
|
|
3677
|
+
return first("local-operator") ?? first("lop");
|
|
3678
|
+
}
|
|
3679
|
+
return resolveCommandPath("local-operator", options) ?? resolveCommandPath("lop", options);
|
|
3680
|
+
}
|
|
3340
3681
|
function readInstallIdentity(shimPath) {
|
|
3341
3682
|
if (!shimPath) return { path: null };
|
|
3342
3683
|
let realPath = null;
|
|
@@ -3363,9 +3704,30 @@ function readInstallIdentity(shimPath) {
|
|
|
3363
3704
|
uvReceipt: existing(path$1.join(prefix, "uv-receipt.toml")),
|
|
3364
3705
|
venvPrefix: fs.existsSync(path$1.join(prefix, "pyvenv.cfg")) ? prefix : null,
|
|
3365
3706
|
installer: markers.installer,
|
|
3366
|
-
editable: markers.editable
|
|
3707
|
+
editable: markers.editable,
|
|
3708
|
+
version: markers.version,
|
|
3709
|
+
sourceRef: markers.sourceRef
|
|
3367
3710
|
};
|
|
3368
3711
|
}
|
|
3712
|
+
function generationInstallRoot(identity) {
|
|
3713
|
+
const resolved = identity.realPath ?? identity.path;
|
|
3714
|
+
if (!resolved) return null;
|
|
3715
|
+
let executable;
|
|
3716
|
+
try {
|
|
3717
|
+
executable = fs.realpathSync(resolved);
|
|
3718
|
+
} catch {
|
|
3719
|
+
return null;
|
|
3720
|
+
}
|
|
3721
|
+
let dir = executable;
|
|
3722
|
+
for (; ; ) {
|
|
3723
|
+
const parent = path$1.dirname(dir);
|
|
3724
|
+
if (parent === dir) return null;
|
|
3725
|
+
if (path$1.basename(parent) === "generations") {
|
|
3726
|
+
return fs.existsSync(path$1.join(path$1.dirname(parent), "current")) ? dir : null;
|
|
3727
|
+
}
|
|
3728
|
+
dir = parent;
|
|
3729
|
+
}
|
|
3730
|
+
}
|
|
3369
3731
|
function classifyGlobalInstall(identity) {
|
|
3370
3732
|
if (!identity.path) return "global-unknown";
|
|
3371
3733
|
if (identity.editable) return "editable";
|
|
@@ -3386,8 +3748,8 @@ function classifyGlobalInstall(identity) {
|
|
|
3386
3748
|
}
|
|
3387
3749
|
function resolveGlobalInstallPlan(input) {
|
|
3388
3750
|
const kind = classifyGlobalInstall(input.identity);
|
|
3389
|
-
const sourceBuild = kind === "editable" || kind === "uv-tool" &&
|
|
3390
|
-
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}
|
|
3751
|
+
const sourceBuild = kind === "editable" || kind === "uv-tool" && isSourceBuildRef(input.identity.sourceRef);
|
|
3752
|
+
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.";
|
|
3391
3753
|
if (kind === "editable") {
|
|
3392
3754
|
return {
|
|
3393
3755
|
canManageUpdate: false,
|
|
@@ -3398,12 +3760,33 @@ function resolveGlobalInstallPlan(input) {
|
|
|
3398
3760
|
};
|
|
3399
3761
|
}
|
|
3400
3762
|
if (kind === "uv-tool") {
|
|
3401
|
-
const
|
|
3763
|
+
const managed = generationInstallRoot(input.identity) !== null;
|
|
3764
|
+
const provenance = sourceBuild ? " Built from source on this machine, so `lop update` installs the published release over it - the harness prints the same notice." : "";
|
|
3402
3765
|
return {
|
|
3403
|
-
canManageUpdate:
|
|
3404
|
-
updateCommand:
|
|
3405
|
-
|
|
3406
|
-
|
|
3766
|
+
canManageUpdate: managed,
|
|
3767
|
+
updateCommand: "lop update",
|
|
3768
|
+
/*
|
|
3769
|
+
* The remedy is the sentence above the command well in the by-hand panel
|
|
3770
|
+
* and the consequence line in the managed offer, and each arm says the
|
|
3771
|
+
* thing that decides the reader's next move:
|
|
3772
|
+
*
|
|
3773
|
+
* Managed - what the app is about to do TO THEM. This arm used to tell the
|
|
3774
|
+
* user to go to a terminal on the one path where the app runs the command
|
|
3775
|
+
* itself, which no surface rendered (reviews D7, U7), and the offer named
|
|
3776
|
+
* no cost at all before the click (review U3). The restart is the one
|
|
3777
|
+
* destructive thing this path does, so it is stated here rather than in
|
|
3778
|
+
* the in-flight panel the user reaches only after pressing.
|
|
3779
|
+
*
|
|
3780
|
+
* Legacy - WHY the app will not press the button, which is what the user
|
|
3781
|
+
* is choosing between (reviews U8, N1). It lived only in the mono Details
|
|
3782
|
+
* line, trailing a resolved path and a classification.
|
|
3783
|
+
*
|
|
3784
|
+
* NEITHER ENDS IN A COLON: the well below is visually distinct in both
|
|
3785
|
+
* panels, and in the by-hand panel the next line was the version sentence,
|
|
3786
|
+
* so the promise landed on the wrong line (review D5).
|
|
3787
|
+
*/
|
|
3788
|
+
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.",
|
|
3789
|
+
detail: `${detail}${provenance}`,
|
|
3407
3790
|
sourceBuild
|
|
3408
3791
|
};
|
|
3409
3792
|
}
|
|
@@ -3411,7 +3794,7 @@ function resolveGlobalInstallPlan(input) {
|
|
|
3411
3794
|
return {
|
|
3412
3795
|
canManageUpdate: false,
|
|
3413
3796
|
updateCommand: "pipx upgrade local-operator",
|
|
3414
|
-
remedy: "The server is a pipx install, so update it from your terminal
|
|
3797
|
+
remedy: "The server is a pipx install, so update it from your terminal",
|
|
3415
3798
|
detail,
|
|
3416
3799
|
sourceBuild: false
|
|
3417
3800
|
};
|
|
@@ -3420,7 +3803,7 @@ function resolveGlobalInstallPlan(input) {
|
|
|
3420
3803
|
return {
|
|
3421
3804
|
canManageUpdate: false,
|
|
3422
3805
|
updateCommand: "pip install --upgrade local-operator",
|
|
3423
|
-
remedy: "The server is a pip install, so update it from your terminal
|
|
3806
|
+
remedy: "The server is a pip install, so update it from your terminal",
|
|
3424
3807
|
detail,
|
|
3425
3808
|
sourceBuild: false
|
|
3426
3809
|
};
|
|
@@ -3428,7 +3811,7 @@ function resolveGlobalInstallPlan(input) {
|
|
|
3428
3811
|
return {
|
|
3429
3812
|
canManageUpdate: false,
|
|
3430
3813
|
updateCommand: "",
|
|
3431
|
-
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
|
|
3814
|
+
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",
|
|
3432
3815
|
detail,
|
|
3433
3816
|
sourceBuild: false
|
|
3434
3817
|
};
|
|
@@ -3465,9 +3848,11 @@ function parsePipShowVersion(stdout) {
|
|
|
3465
3848
|
function didUpgradeLand(input) {
|
|
3466
3849
|
const { before, after, target: target2 } = input;
|
|
3467
3850
|
if (after == null) return false;
|
|
3468
|
-
if (
|
|
3469
|
-
|
|
3851
|
+
if (target2 != null) {
|
|
3852
|
+
const order = compareVersions$1(after, target2);
|
|
3853
|
+
return order !== null && order >= 0;
|
|
3470
3854
|
}
|
|
3855
|
+
if (before == null) return false;
|
|
3471
3856
|
return before.trim() !== after.trim();
|
|
3472
3857
|
}
|
|
3473
3858
|
const launchEnv = {
|
|
@@ -5393,8 +5778,6 @@ var LocalOperatorStartupMode = /* @__PURE__ */ ((LocalOperatorStartupMode2) => {
|
|
|
5393
5778
|
LocalOperatorStartupMode2["NOT_STARTED"] = "NOT_STARTED";
|
|
5394
5779
|
return LocalOperatorStartupMode2;
|
|
5395
5780
|
})(LocalOperatorStartupMode || {});
|
|
5396
|
-
const CONSOLE_DISCOVERY_TIMEOUT_MS = 5e3;
|
|
5397
|
-
const CONSOLE_RESOLUTION_WORST_MS = 2 * CONSOLE_DISCOVERY_TIMEOUT_MS;
|
|
5398
5781
|
const SHUTDOWN_TIMEOUT_DEFAULTS = {
|
|
5399
5782
|
restart: 1e4,
|
|
5400
5783
|
// 10 seconds for restart operations
|
|
@@ -5408,7 +5791,6 @@ const READINESS_POLL_INTERVAL_MS = 1e3;
|
|
|
5408
5791
|
const DESKTOP_TOKEN_FILENAME = "desktop-token";
|
|
5409
5792
|
const execPromise = node_util.promisify(node_child_process.exec);
|
|
5410
5793
|
const ENV_VAR_REGEX = /^([^=]+)=(.*)$/;
|
|
5411
|
-
const LINE_BREAK = /\r?\n/;
|
|
5412
5794
|
function classifyDesktopAnswer(status2) {
|
|
5413
5795
|
if (status2 >= 200 && status2 < 300) return "accepted";
|
|
5414
5796
|
if (status2 === 401 || status2 === 403) return "refused";
|
|
@@ -6093,59 +6475,75 @@ class BackendServiceManager {
|
|
|
6093
6475
|
}
|
|
6094
6476
|
this.notifyStatus();
|
|
6095
6477
|
}
|
|
6478
|
+
/**
|
|
6479
|
+
* The global launcher this app ranks its daemons by, spawns from, and reports.
|
|
6480
|
+
*
|
|
6481
|
+
* ONE resolution, read by `checkLocalOperatorExists`, `resolveGlobalConsole`
|
|
6482
|
+
* and `preferredInstallPrefix`, because the three have to name the same
|
|
6483
|
+
* install: the decision to run GLOBAL_INSTALL is only true if the spawn can
|
|
6484
|
+
* resolve that same launcher, and a second resolution through a different
|
|
6485
|
+
* mechanism is exactly how a shell rc file's prepended venv becomes the thing
|
|
6486
|
+
* that gets spawned while the ranking talked about a different one.
|
|
6487
|
+
*
|
|
6488
|
+
* `resolveCommandPath`, NOT `which`. WHY, measured on this host 2026-09-16: the
|
|
6489
|
+
* app is started by LaunchServices, whose PATH is `/usr/bin:/bin:/usr/sbin:/sbin`,
|
|
6490
|
+
* and an app launched from a session that carries its own minimal PATH inherits
|
|
6491
|
+
* that. `~/.local/bin` - where uv and pipx link their console scripts, and where
|
|
6492
|
+
* this machine's `lop-update` install lives - is on neither. So the shell probe
|
|
6493
|
+
* this replaced answered "not found globally" on every ordinary launch, and the
|
|
6494
|
+
* app fell through to its OWN bundled environment: it ran a backend the
|
|
6495
|
+
* operator never updates, watched it diverge from `lop --version` (0.55.9
|
|
6496
|
+
* bundled against 0.55.10 installed), and offered to `pip install` into a uv
|
|
6497
|
+
* tool. `resolveCommandPath` searches the installers' own locations
|
|
6498
|
+
* (`UV_TOOL_BIN_DIR`, `XDG_BIN_HOME`, `$XDG_DATA_HOME/../bin`, `~/.local/bin`,
|
|
6499
|
+
* the Homebrew prefixes and every uv tool environment) as well as the inherited
|
|
6500
|
+
* PATH, so the same install is named with or without a shell - which is the
|
|
6501
|
+
* ranking rule `preferredInstallPrefix` states, and precisely what a
|
|
6502
|
+
* `which`-based check could not honour.
|
|
6503
|
+
*
|
|
6504
|
+
* `local-operator` before `lop`: both are console scripts of the same install
|
|
6505
|
+
* and normally sit in the same bin directory, but the spawn reads the resolved
|
|
6506
|
+
* script's shebang and refuses anything that is not a
|
|
6507
|
+
* `from local_operator.cli import main` launcher (`consoleInterpreter`), so the
|
|
6508
|
+
* name this app has always spawned is tried first and `lop` covers an install
|
|
6509
|
+
* whose older console script is gone.
|
|
6510
|
+
*/
|
|
6511
|
+
globalConsoleScript() {
|
|
6512
|
+
return resolveGlobalConsoleScript();
|
|
6513
|
+
}
|
|
6096
6514
|
/**
|
|
6097
6515
|
* Check if the local-operator command exists globally
|
|
6098
6516
|
* @returns Promise resolving to true if the command exists, false otherwise
|
|
6099
6517
|
*/
|
|
6100
6518
|
async checkLocalOperatorExists() {
|
|
6101
|
-
|
|
6102
|
-
|
|
6519
|
+
const command = this.globalConsoleScript();
|
|
6520
|
+
if (!command) {
|
|
6103
6521
|
logger.info(
|
|
6104
|
-
|
|
6522
|
+
"local-operator command not found globally",
|
|
6105
6523
|
LogFileType.BACKEND
|
|
6106
6524
|
);
|
|
6107
|
-
|
|
6108
|
-
|
|
6109
|
-
|
|
6110
|
-
|
|
6111
|
-
|
|
6112
|
-
|
|
6113
|
-
|
|
6114
|
-
|
|
6115
|
-
|
|
6116
|
-
|
|
6117
|
-
|
|
6118
|
-
"Library",
|
|
6119
|
-
"Application Support",
|
|
6120
|
-
"Local Operator"
|
|
6121
|
-
)
|
|
6122
|
-
)) {
|
|
6123
|
-
logger.info(
|
|
6124
|
-
"The PATH command belongs to a legacy managed environment; preparing a separate backend instead",
|
|
6125
|
-
LogFileType.BACKEND
|
|
6126
|
-
);
|
|
6127
|
-
return false;
|
|
6128
|
-
}
|
|
6129
|
-
logger.info(
|
|
6130
|
-
`local-operator command found at: ${stdout.trim()}`,
|
|
6131
|
-
LogFileType.BACKEND
|
|
6132
|
-
);
|
|
6133
|
-
return true;
|
|
6134
|
-
}
|
|
6135
|
-
} catch (_error) {
|
|
6525
|
+
return false;
|
|
6526
|
+
}
|
|
6527
|
+
if (process.platform === "darwin" && isLegacyManagedCommand(
|
|
6528
|
+
command,
|
|
6529
|
+
path$1.join(
|
|
6530
|
+
electron.app.getPath("home"),
|
|
6531
|
+
"Library",
|
|
6532
|
+
"Application Support",
|
|
6533
|
+
"Local Operator"
|
|
6534
|
+
)
|
|
6535
|
+
)) {
|
|
6136
6536
|
logger.info(
|
|
6137
|
-
|
|
6537
|
+
`The resolved command belongs to a legacy managed environment (${command}); preparing a separate backend instead`,
|
|
6138
6538
|
LogFileType.BACKEND
|
|
6139
6539
|
);
|
|
6540
|
+
return false;
|
|
6140
6541
|
}
|
|
6141
|
-
|
|
6142
|
-
|
|
6143
|
-
|
|
6144
|
-
|
|
6145
|
-
|
|
6146
|
-
*/
|
|
6147
|
-
canonicalLauncher() {
|
|
6148
|
-
return resolveCommandPath("lop") ?? resolveCommandPath("local-operator");
|
|
6542
|
+
logger.info(
|
|
6543
|
+
`local-operator command found at: ${command}`,
|
|
6544
|
+
LogFileType.BACKEND
|
|
6545
|
+
);
|
|
6546
|
+
return true;
|
|
6149
6547
|
}
|
|
6150
6548
|
/**
|
|
6151
6549
|
* `sys.prefix` of the install the user's own `lop` runs, when it can be named.
|
|
@@ -6160,7 +6558,7 @@ class BackendServiceManager {
|
|
|
6160
6558
|
*/
|
|
6161
6559
|
preferredInstallPrefix() {
|
|
6162
6560
|
try {
|
|
6163
|
-
const identity = readInstallIdentity(this.
|
|
6561
|
+
const identity = readInstallIdentity(this.globalConsoleScript());
|
|
6164
6562
|
if (identity.uvReceipt) return path$1.dirname(identity.uvReceipt);
|
|
6165
6563
|
if (identity.venvPrefix) return identity.venvPrefix;
|
|
6166
6564
|
return null;
|
|
@@ -6539,14 +6937,32 @@ class BackendServiceManager {
|
|
|
6539
6937
|
* Start the backend service
|
|
6540
6938
|
* @returns Promise resolving to true if the backend was started successfully, false otherwise
|
|
6541
6939
|
*/
|
|
6542
|
-
/**
|
|
6543
|
-
*
|
|
6544
|
-
|
|
6545
|
-
|
|
6546
|
-
|
|
6547
|
-
|
|
6548
|
-
|
|
6549
|
-
|
|
6940
|
+
/** The global console to launch, resolved the one shell-free way.
|
|
6941
|
+
*
|
|
6942
|
+
* `ownedServeLaunch` still proves the identity of what it is handed rather than
|
|
6943
|
+
* trusting the name it was resolved under - that part is unchanged. What
|
|
6944
|
+
* changed is WHERE the name comes from: this method used to re-resolve a bare
|
|
6945
|
+
* `local-operator` through a shell, in the spawn environment that carries the
|
|
6946
|
+
* login shell's PATH. That is the second resolution `globalConsoleScript`
|
|
6947
|
+
* warns about, and it could name a venv a shell rc file prepends while the
|
|
6948
|
+
* decision above had ranked the operator's real install - two answers to one
|
|
6949
|
+
* question, with the spawn using the one the ranking never saw. Reading the
|
|
6950
|
+
* same helper removes the possibility instead of documenting it.
|
|
6951
|
+
*
|
|
6952
|
+
* The throw is reachable only when the resolution succeeded a moment ago and
|
|
6953
|
+
* the install disappeared since. It is deliberately not swallowed into an
|
|
6954
|
+
* empty string: `consoleInterpreter("")` would report that as an ENOENT on a
|
|
6955
|
+
* path nobody printed, and the caller's own failure report is the honest face
|
|
6956
|
+
* of this.
|
|
6957
|
+
*/
|
|
6958
|
+
async resolveGlobalConsole() {
|
|
6959
|
+
const command = this.globalConsoleScript();
|
|
6960
|
+
if (!command) {
|
|
6961
|
+
throw new Error(
|
|
6962
|
+
"No global local-operator command could be resolved, though one was found a moment ago"
|
|
6963
|
+
);
|
|
6964
|
+
}
|
|
6965
|
+
return command;
|
|
6550
6966
|
}
|
|
6551
6967
|
/**
|
|
6552
6968
|
* Start a daemon this app owns, or attach to one that already exists.
|
|
@@ -6619,7 +7035,7 @@ class BackendServiceManager {
|
|
|
6619
7035
|
let interpreters;
|
|
6620
7036
|
if (globalInstall) {
|
|
6621
7037
|
this.startupMode = "GLOBAL_INSTALL";
|
|
6622
|
-
const executable = await this.resolveGlobalConsole(
|
|
7038
|
+
const executable = await this.resolveGlobalConsole();
|
|
6623
7039
|
interpreters = process.platform === "win32" ? await windowsInterpreterCandidates(executable, env) : [consoleInterpreter(executable)];
|
|
6624
7040
|
} else {
|
|
6625
7041
|
this.startupMode = "APP_BUNDLED_VENV";
|
|
@@ -7383,6 +7799,20 @@ class BackendServiceManager {
|
|
|
7383
7799
|
getOwnedPid() {
|
|
7384
7800
|
return this.process?.pid ?? null;
|
|
7385
7801
|
}
|
|
7802
|
+
/**
|
|
7803
|
+
* The address this app is talking to RIGHT NOW.
|
|
7804
|
+
*
|
|
7805
|
+
* Not the configured target: `attachTo` rotates `backendUrl` onto the daemon
|
|
7806
|
+
* discovery adopted, and everything that queries the backend resolves against
|
|
7807
|
+
* it at call time. A consumer that instead read the configured URL described a
|
|
7808
|
+
* daemon the app is not talking to - the update service's `/health` read did
|
|
7809
|
+
* exactly that, so for an adopted daemon every version read failed and the
|
|
7810
|
+
* panel could neither name the build being served nor report the skew after an
|
|
7811
|
+
* install moved (QA Q-2, UX U1).
|
|
7812
|
+
*/
|
|
7813
|
+
getBackendUrl() {
|
|
7814
|
+
return this.backendUrl;
|
|
7815
|
+
}
|
|
7386
7816
|
/**
|
|
7387
7817
|
* Check if the backend service is using an external backend
|
|
7388
7818
|
* @returns True if using an external backend, false if we started our own
|
|
@@ -15410,6 +15840,9 @@ function runCommand(command, args, options = {}) {
|
|
|
15410
15840
|
);
|
|
15411
15841
|
});
|
|
15412
15842
|
}
|
|
15843
|
+
function serverUpdateLogPath() {
|
|
15844
|
+
return path$1.join(logger.getLogPath(), LogFileType.UPDATE_SERVICE);
|
|
15845
|
+
}
|
|
15413
15846
|
function readCommandOutput(command, args) {
|
|
15414
15847
|
try {
|
|
15415
15848
|
const output = node_child_process.execFileSync(command, args, {
|
|
@@ -15421,6 +15854,11 @@ function readCommandOutput(command, args) {
|
|
|
15421
15854
|
return null;
|
|
15422
15855
|
}
|
|
15423
15856
|
}
|
|
15857
|
+
const INSTALLER_PROBE_TIMEOUT_MS = 5e3;
|
|
15858
|
+
function stderrTail(stderr, maxLines = 4) {
|
|
15859
|
+
const lines = stderr.split("\n").map((line) => line.trim()).filter((line) => line.length > 0);
|
|
15860
|
+
return lines.slice(-maxLines).join("\n").slice(-600);
|
|
15861
|
+
}
|
|
15424
15862
|
class UpdateService {
|
|
15425
15863
|
mainWindow = null;
|
|
15426
15864
|
isDevMode;
|
|
@@ -17266,19 +17704,39 @@ ${heal.removed.join("\n")}`,
|
|
|
17266
17704
|
if (v1PreRelease && !v2PreRelease) return false;
|
|
17267
17705
|
return v1PreRelease > v2PreRelease;
|
|
17268
17706
|
}
|
|
17707
|
+
/**
|
|
17708
|
+
* The address every backend read in this service goes to.
|
|
17709
|
+
*
|
|
17710
|
+
* The manager's LIVE address rather than the configured one: an adopted daemon
|
|
17711
|
+
* is discovered at a port the configuration never named, and the manager rotates
|
|
17712
|
+
* its own `backendUrl` onto it when it attaches. Reading the configured URL
|
|
17713
|
+
* instead made every health read fail for exactly the shape this branch adds -
|
|
17714
|
+
* a daemon the app did not start - so the skew after an install moved degraded
|
|
17715
|
+
* to "no reading" on the one path where that sentence is the only report the
|
|
17716
|
+
* user gets, while Settings could name the same daemon's version a few clicks
|
|
17717
|
+
* away (QA Q-2, UX U1).
|
|
17718
|
+
*
|
|
17719
|
+
* The configured URL stays as the fallback for the frames where no manager is
|
|
17720
|
+
* attached yet - the update service is constructed before it in the app's start
|
|
17721
|
+
* path.
|
|
17722
|
+
*/
|
|
17723
|
+
liveBackendUrl() {
|
|
17724
|
+
return this.backendService?.getBackendUrl() ?? this.backendUrl;
|
|
17725
|
+
}
|
|
17269
17726
|
/**
|
|
17270
17727
|
* Get the installed backend version using the health API
|
|
17271
17728
|
* @returns Promise resolving to the installed version or null if not found
|
|
17272
17729
|
*/
|
|
17273
17730
|
async getInstalledBackendVersion() {
|
|
17274
17731
|
try {
|
|
17732
|
+
const backendUrl = this.liveBackendUrl();
|
|
17275
17733
|
logger.info(
|
|
17276
|
-
`Checking backend version from health API at ${
|
|
17734
|
+
`Checking backend version from health API at ${backendUrl}/health`,
|
|
17277
17735
|
LogFileType.UPDATE_SERVICE
|
|
17278
17736
|
);
|
|
17279
17737
|
const controller = new AbortController();
|
|
17280
17738
|
const timeoutId = setTimeout(() => controller.abort(), 5e3);
|
|
17281
|
-
const response = await fetch(`${
|
|
17739
|
+
const response = await fetch(`${backendUrl}/health`, {
|
|
17282
17740
|
method: "GET",
|
|
17283
17741
|
headers: { Accept: "application/json" },
|
|
17284
17742
|
signal: controller.signal
|
|
@@ -17310,9 +17768,10 @@ ${heal.removed.join("\n")}`,
|
|
|
17310
17768
|
LogFileType.UPDATE_SERVICE,
|
|
17311
17769
|
error
|
|
17312
17770
|
);
|
|
17313
|
-
|
|
17771
|
+
const retryUrl = this.liveBackendUrl();
|
|
17772
|
+
if (retryUrl.includes("127.0.0.1")) {
|
|
17314
17773
|
try {
|
|
17315
|
-
const altUrl =
|
|
17774
|
+
const altUrl = retryUrl.replace("127.0.0.1", "localhost");
|
|
17316
17775
|
logger.info(
|
|
17317
17776
|
`Trying alternative URL: ${altUrl}/health`,
|
|
17318
17777
|
LogFileType.UPDATE_SERVICE
|
|
@@ -17433,12 +17892,10 @@ ${heal.removed.join("\n")}`,
|
|
|
17433
17892
|
*/
|
|
17434
17893
|
async resolveBackendUpdatePlan(startupMode) {
|
|
17435
17894
|
if (startupMode === LocalOperatorStartupMode.EXISTING_SERVER || startupMode === LocalOperatorStartupMode.GLOBAL_INSTALL) {
|
|
17436
|
-
const
|
|
17437
|
-
|
|
17438
|
-
lopUpdatePath: this.resolveLopUpdatePath()
|
|
17439
|
-
});
|
|
17895
|
+
const identity = await this.resolveInstallIdentity();
|
|
17896
|
+
const plan = resolveGlobalInstallPlan({ identity });
|
|
17440
17897
|
logger.info(
|
|
17441
|
-
`External backend install (${startupMode}): ${plan.detail}; remedy is \`${plan.updateCommand || "nothing the app can name"}
|
|
17898
|
+
`External backend install (${startupMode}): ${plan.detail}; remedy is \`${plan.updateCommand || "nothing the app can name"}\`; install version ${identity.version ?? "unreadable"}`,
|
|
17442
17899
|
LogFileType.UPDATE_SERVICE
|
|
17443
17900
|
);
|
|
17444
17901
|
return {
|
|
@@ -17446,7 +17903,8 @@ ${heal.removed.join("\n")}`,
|
|
|
17446
17903
|
updateCommand: plan.updateCommand,
|
|
17447
17904
|
remedy: plan.remedy,
|
|
17448
17905
|
detail: plan.detail,
|
|
17449
|
-
sourceBuild: plan.sourceBuild
|
|
17906
|
+
sourceBuild: plan.sourceBuild,
|
|
17907
|
+
installedInstallVersion: identity.version ?? null
|
|
17450
17908
|
};
|
|
17451
17909
|
}
|
|
17452
17910
|
return {
|
|
@@ -17454,7 +17912,8 @@ ${heal.removed.join("\n")}`,
|
|
|
17454
17912
|
updateCommand: "pip install --upgrade local-operator",
|
|
17455
17913
|
remedy: "Updating the server will improve AI functionality.",
|
|
17456
17914
|
detail: `The app started this server itself (${startupMode}).`,
|
|
17457
|
-
sourceBuild: false
|
|
17915
|
+
sourceBuild: false,
|
|
17916
|
+
installedInstallVersion: null
|
|
17458
17917
|
};
|
|
17459
17918
|
}
|
|
17460
17919
|
/**
|
|
@@ -17473,25 +17932,7 @@ ${heal.removed.join("\n")}`,
|
|
|
17473
17932
|
* searches the machine's and the user's own install locations.
|
|
17474
17933
|
*/
|
|
17475
17934
|
resolveLocalOperatorPath() {
|
|
17476
|
-
|
|
17477
|
-
const first = readCommandOutput("where", ["local-operator"])?.split("\n")[0]?.trim();
|
|
17478
|
-
return first && first.length > 0 ? first : null;
|
|
17479
|
-
}
|
|
17480
|
-
return resolveCommandPath("local-operator");
|
|
17481
|
-
}
|
|
17482
|
-
/**
|
|
17483
|
-
* Path the `lop-update` shim resolves to, or null when this machine has none.
|
|
17484
|
-
*
|
|
17485
|
-
* The same problem as the shim above, for the remedy rather than the
|
|
17486
|
-
* classification: `lop-update` is what the source-build wording tells the user
|
|
17487
|
-
* to run, and it lives in `~/.local/bin`, which a Finder launch cannot see
|
|
17488
|
-
* through PATH alone - so the plan fell back to the pip/pipx wording for an
|
|
17489
|
-
* install that is neither. It is a shell script, so there is no Windows
|
|
17490
|
-
* counterpart to look for.
|
|
17491
|
-
*/
|
|
17492
|
-
resolveLopUpdatePath() {
|
|
17493
|
-
if (process.platform === "win32") return null;
|
|
17494
|
-
return resolveCommandPath("lop-update");
|
|
17935
|
+
return resolveGlobalConsoleScript();
|
|
17495
17936
|
}
|
|
17496
17937
|
/**
|
|
17497
17938
|
* Everything `classifyGlobalInstall` needs to tell a uv tool install from a
|
|
@@ -17503,17 +17944,44 @@ ${heal.removed.join("\n")}`,
|
|
|
17503
17944
|
* answered: spawning `uv` and `pipx` on every backend check for a question
|
|
17504
17945
|
* three string tests settled is work nobody asked for.
|
|
17505
17946
|
*/
|
|
17506
|
-
resolveInstallIdentity() {
|
|
17947
|
+
async resolveInstallIdentity() {
|
|
17507
17948
|
const identity = readInstallIdentity(this.resolveLocalOperatorPath());
|
|
17508
17949
|
if (identity.path && classifyGlobalInstall(identity) !== "global-unknown") {
|
|
17509
17950
|
return identity;
|
|
17510
17951
|
}
|
|
17511
17952
|
const uv = resolveCommandPath("uv");
|
|
17512
17953
|
const pipx = resolveCommandPath("pipx");
|
|
17513
|
-
|
|
17514
|
-
|
|
17954
|
+
const [uvToolList, pipxList] = await Promise.all([
|
|
17955
|
+
uv ? this.probeInstallerList(uv, ["tool", "list"]) : null,
|
|
17956
|
+
pipx ? this.probeInstallerList(pipx, ["list"]) : null
|
|
17957
|
+
]);
|
|
17958
|
+
identity.uvToolList = uvToolList;
|
|
17959
|
+
identity.pipxList = pipxList;
|
|
17515
17960
|
return identity;
|
|
17516
17961
|
}
|
|
17962
|
+
/**
|
|
17963
|
+
* What an installer's own list command answers, or null.
|
|
17964
|
+
*
|
|
17965
|
+
* ASYNC, and that is the R1-4 fix: this runs on every backend check for an
|
|
17966
|
+
* install the three string tests cannot classify, and the synchronous form of it
|
|
17967
|
+
* blocked the main thread for up to 5 s per probe - 10 s for the pair, with the
|
|
17968
|
+
* window and every IPC channel frozen behind it. Review R1-4 measured it; the
|
|
17969
|
+
* answer is identical, only the waiting is not done on the event loop.
|
|
17970
|
+
*
|
|
17971
|
+
* `runCommand` rather than a second runner, and with `pythonSpawnEnv()`: `uv` and
|
|
17972
|
+
* `pipx` are python programs, so they get the same guarded environment every
|
|
17973
|
+
* other python spawn here passes - which is what keeps bytecode they write out
|
|
17974
|
+
* of the installed app.
|
|
17975
|
+
*/
|
|
17976
|
+
async probeInstallerList(command, args) {
|
|
17977
|
+
const result = await runCommand(command, args, {
|
|
17978
|
+
timeoutMs: INSTALLER_PROBE_TIMEOUT_MS,
|
|
17979
|
+
env: this.pythonSpawnEnv()
|
|
17980
|
+
});
|
|
17981
|
+
if (!result.ran) return null;
|
|
17982
|
+
const output = result.stdout.trim();
|
|
17983
|
+
return output.length > 0 ? output : null;
|
|
17984
|
+
}
|
|
17517
17985
|
/**
|
|
17518
17986
|
* Check the installed server against the published release.
|
|
17519
17987
|
*
|
|
@@ -17528,6 +17996,7 @@ ${heal.removed.join("\n")}`,
|
|
|
17528
17996
|
`Checking for backend updates... (silent mode: ${silent})`,
|
|
17529
17997
|
LogFileType.UPDATE_SERVICE
|
|
17530
17998
|
);
|
|
17999
|
+
await this.reportUnattendedServerUpdate();
|
|
17531
18000
|
try {
|
|
17532
18001
|
if (this.isDevMode) {
|
|
17533
18002
|
logger.info(
|
|
@@ -17557,9 +18026,12 @@ ${heal.removed.join("\n")}`,
|
|
|
17557
18026
|
);
|
|
17558
18027
|
return { status: "unavailable", info: null };
|
|
17559
18028
|
}
|
|
17560
|
-
const
|
|
18029
|
+
const plan = await this.resolveBackendUpdatePlan(startupMode);
|
|
18030
|
+
const runningVersion = await this.getInstalledBackendVersion();
|
|
17561
18031
|
const latestVersion = await this.getLatestPypiVersion();
|
|
17562
18032
|
if (latestVersion) this.lastPublishedBackendVersion = latestVersion;
|
|
18033
|
+
const installVersion = plan.installedInstallVersion;
|
|
18034
|
+
const installedVersion = installVersion && isReadableVersion(installVersion) ? installVersion : runningVersion;
|
|
17563
18035
|
if (!installedVersion || !latestVersion) {
|
|
17564
18036
|
logger.error(
|
|
17565
18037
|
"Unable to determine backend versions.",
|
|
@@ -17568,7 +18040,8 @@ ${heal.removed.join("\n")}`,
|
|
|
17568
18040
|
if (reportFailure) {
|
|
17569
18041
|
this.sendToRenderer("backend-update-error", {
|
|
17570
18042
|
message: "Unable to determine backend version.",
|
|
17571
|
-
phase: "check"
|
|
18043
|
+
phase: "check",
|
|
18044
|
+
logPath: serverUpdateLogPath()
|
|
17572
18045
|
});
|
|
17573
18046
|
}
|
|
17574
18047
|
return { status: "unavailable", info: null };
|
|
@@ -17581,7 +18054,8 @@ ${heal.removed.join("\n")}`,
|
|
|
17581
18054
|
if (reportFailure) {
|
|
17582
18055
|
this.sendToRenderer("backend-update-error", {
|
|
17583
18056
|
message: "The installed server version could not be determined, so no update was offered. Restart the app to try again.",
|
|
17584
|
-
phase: "check"
|
|
18057
|
+
phase: "check",
|
|
18058
|
+
logPath: serverUpdateLogPath()
|
|
17585
18059
|
});
|
|
17586
18060
|
}
|
|
17587
18061
|
return { status: "unavailable", info: null };
|
|
@@ -17600,12 +18074,17 @@ ${heal.removed.join("\n")}`,
|
|
|
17600
18074
|
if (reportFailure) {
|
|
17601
18075
|
this.sendToRenderer("backend-update-error", {
|
|
17602
18076
|
message: "Unable to determine backend version.",
|
|
17603
|
-
phase: "check"
|
|
18077
|
+
phase: "check",
|
|
18078
|
+
logPath: serverUpdateLogPath()
|
|
17604
18079
|
});
|
|
17605
18080
|
}
|
|
17606
18081
|
return { status: "unavailable", info: null };
|
|
17607
18082
|
}
|
|
17608
18083
|
const shouldUpdate = this.isNewerVersion(latestVersion, installedVersion);
|
|
18084
|
+
logger.info(
|
|
18085
|
+
`Install on disk reports: ${installVersion ?? "no reading"}, running backend reports: ${runningVersion ?? "no reading"}, Latest: ${latestVersion}, Update needed: ${shouldUpdate}, Startup mode: ${startupMode}`,
|
|
18086
|
+
LogFileType.UPDATE_SERVICE
|
|
18087
|
+
);
|
|
17609
18088
|
logger.info(
|
|
17610
18089
|
`Installed backend version: ${installedVersion}, Latest: ${latestVersion}, Update needed: ${shouldUpdate}, Startup mode: ${startupMode}`,
|
|
17611
18090
|
LogFileType.UPDATE_SERVICE
|
|
@@ -17615,16 +18094,29 @@ ${heal.removed.join("\n")}`,
|
|
|
17615
18094
|
`New backend version available: ${latestVersion} (installed: ${installedVersion})`,
|
|
17616
18095
|
LogFileType.UPDATE_SERVICE
|
|
17617
18096
|
);
|
|
17618
|
-
const { canManageUpdate, updateCommand, remedy, detail, sourceBuild } = await this.resolveBackendUpdatePlan(startupMode);
|
|
17619
18097
|
const updateInfo = {
|
|
17620
18098
|
currentVersion: installedVersion,
|
|
17621
18099
|
latestVersion,
|
|
17622
|
-
|
|
17623
|
-
|
|
18100
|
+
runningVersion,
|
|
18101
|
+
updateCommand: plan.updateCommand,
|
|
18102
|
+
canManageUpdate: plan.canManageUpdate,
|
|
17624
18103
|
startupMode,
|
|
17625
|
-
remedy,
|
|
17626
|
-
detail,
|
|
17627
|
-
sourceBuild,
|
|
18104
|
+
remedy: plan.remedy,
|
|
18105
|
+
detail: plan.detail,
|
|
18106
|
+
sourceBuild: plan.sourceBuild,
|
|
18107
|
+
/*
|
|
18108
|
+
* WHO THE APP WOULD RESTART, on the panel where the user decides (UX U9).
|
|
18109
|
+
*
|
|
18110
|
+
* The managed arm's consequence sentence comes from the plan, and the plan is
|
|
18111
|
+
* an INSTALL classification - it cannot know whether the daemon serving this
|
|
18112
|
+
* app is one the app started. On a machine where discovery ADOPTED a server,
|
|
18113
|
+
* the offer therefore promised a restart that cannot happen, and the app's
|
|
18114
|
+
* own completion notice then said the opposite ("Local Operator does not
|
|
18115
|
+
* restart a server it did not start"). The sentence is right for the arm it
|
|
18116
|
+
* was written for; what was missing is the reading that decides it, which the
|
|
18117
|
+
* other two events already carry. It travels here too.
|
|
18118
|
+
*/
|
|
18119
|
+
restartable: this.backendIsAppOwned(),
|
|
17628
18120
|
/* `silent` is the whole difference between the two callers: the periodic
|
|
17629
18121
|
and start-up checks pass `true`, the IPC handlers behind the buttons
|
|
17630
18122
|
pass `false`. The renderer needs to know which one it is answering,
|
|
@@ -17644,9 +18136,23 @@ ${heal.removed.join("\n")}`,
|
|
|
17644
18136
|
`No new backend version available. (installed: ${installedVersion}, latest: ${latestVersion})`,
|
|
17645
18137
|
LogFileType.UPDATE_SERVICE
|
|
17646
18138
|
);
|
|
17647
|
-
|
|
18139
|
+
const readingsDiffer = installedVersion !== null && runningVersion !== null && isReadableVersion(installedVersion) && isReadableVersion(runningVersion) && installedVersion.trim() !== runningVersion.trim();
|
|
18140
|
+
if ((!silent || readingsDiffer) && this.mainWindow && !this.mainWindow.isDestroyed() && this.mainWindow.webContents && !this.mainWindow.webContents.isDestroyed()) {
|
|
17648
18141
|
this.mainWindow.webContents.send("backend-update-not-available", {
|
|
17649
|
-
version: installedVersion
|
|
18142
|
+
version: installedVersion,
|
|
18143
|
+
/*
|
|
18144
|
+
* The second reading travels on THIS state too, which is the one QA
|
|
18145
|
+
* Q-1 found: an install already at the published version whose daemon
|
|
18146
|
+
* still serves the old build offers nothing, so the offer's detail was
|
|
18147
|
+
* never rendered and the user was told nothing at all - while the log
|
|
18148
|
+
* carried both readings and Settings could name the daemon. It is the
|
|
18149
|
+
* state design §5 exists for (right after a landed install, before the
|
|
18150
|
+
* restart), and no later check re-offers it, because the install itself
|
|
18151
|
+
* is up to date.
|
|
18152
|
+
*/
|
|
18153
|
+
runningVersion,
|
|
18154
|
+
/** Whether the app may restart the daemon that is behind. */
|
|
18155
|
+
restartable: this.backendIsAppOwned()
|
|
17650
18156
|
});
|
|
17651
18157
|
}
|
|
17652
18158
|
return { status: "current", info: null };
|
|
@@ -17666,7 +18172,8 @@ ${heal.removed.join("\n")}`,
|
|
|
17666
18172
|
* keeps that sentence's machine line readable.
|
|
17667
18173
|
*/
|
|
17668
18174
|
message: stripErrorPrefixes(error.message),
|
|
17669
|
-
phase: "check"
|
|
18175
|
+
phase: "check",
|
|
18176
|
+
logPath: serverUpdateLogPath()
|
|
17670
18177
|
});
|
|
17671
18178
|
}
|
|
17672
18179
|
return { status: "unavailable", info: null };
|
|
@@ -17735,7 +18242,11 @@ ${heal.removed.join("\n")}`,
|
|
|
17735
18242
|
const version = await this.getInstalledBackendVersion();
|
|
17736
18243
|
if (version && version !== "Unknown") {
|
|
17737
18244
|
last = version;
|
|
17738
|
-
if (target2 == null
|
|
18245
|
+
if (target2 == null) return version;
|
|
18246
|
+
const order = compareVersions$1(version, target2);
|
|
18247
|
+
if (order === null ? version.trim() === target2.trim() : order >= 0) {
|
|
18248
|
+
return version;
|
|
18249
|
+
}
|
|
17739
18250
|
}
|
|
17740
18251
|
await new Promise((resolve) => setTimeout(resolve, intervalMs));
|
|
17741
18252
|
}
|
|
@@ -17745,6 +18256,281 @@ ${heal.removed.join("\n")}`,
|
|
|
17745
18256
|
);
|
|
17746
18257
|
return null;
|
|
17747
18258
|
}
|
|
18259
|
+
/**
|
|
18260
|
+
* Whether the daemon serving this app is one the APP may restart.
|
|
18261
|
+
*
|
|
18262
|
+
* The answer decides what the skew notice can tell the user to do about a server
|
|
18263
|
+
* still on the old build: an owned daemon is the app's to bounce (so the sentence
|
|
18264
|
+
* names restarting Local Operator, which is an action), while an adopted one is
|
|
18265
|
+
* not (so the sentence says the app will not, which is a fact about what the
|
|
18266
|
+
* reader should expect). Getting this wrong is not cosmetic - the notice would
|
|
18267
|
+
* tell a user of an app-owned daemon that nothing will ever move it.
|
|
18268
|
+
*/
|
|
18269
|
+
backendIsAppOwned() {
|
|
18270
|
+
return this.backendService !== null && !this.backendService.isUsingExternalBackend();
|
|
18271
|
+
}
|
|
18272
|
+
/**
|
|
18273
|
+
* The version the resolved global install reports on disk, or null.
|
|
18274
|
+
*
|
|
18275
|
+
* Shell-free, and deliberately a different read from the running backend's
|
|
18276
|
+
* `/health`: the install is what an update moves, and immediately after one it
|
|
18277
|
+
* is the only reading that has moved - the daemon lags until it is restarted.
|
|
18278
|
+
* Read through the same resolution the plan classified, because it is half of
|
|
18279
|
+
* the evidence that the install changed at all.
|
|
18280
|
+
*/
|
|
18281
|
+
readGlobalInstallVersion() {
|
|
18282
|
+
return readInstallIdentity(this.resolveLocalOperatorPath())?.version ?? null;
|
|
18283
|
+
}
|
|
18284
|
+
/**
|
|
18285
|
+
* Run the install's own front end: `<resolved shim> update`.
|
|
18286
|
+
*
|
|
18287
|
+
* WHY THIS AND NOT AN INSTALLER. The mechanism that owns a uv tool install is
|
|
18288
|
+
* `lop update`: it detects the install kind, honours the `editable`/`unknown`
|
|
18289
|
+
* refusals, writes the `.lop-source` marker runtimes converge on, prunes the
|
|
18290
|
+
* generations nothing refers to, and refreshes the daemons it supervises. An
|
|
18291
|
+
* app that ran `uv` itself would bypass every one of those, and the two
|
|
18292
|
+
* commands this path used to name are the ones the harness documents as
|
|
18293
|
+
* failing (`uv tool upgrade` on a pinned receipt or a git snapshot) or as
|
|
18294
|
+
* belonging to another audience (`lop-update`, the release owner's
|
|
18295
|
+
* build-from-checkout script, which refuses when its ref has diverged).
|
|
18296
|
+
*
|
|
18297
|
+
* THE PATH IS BUILT, NOT INHERITED. `lop update` reaches `uv` by bare name
|
|
18298
|
+
* through the child's environment, and this app is normally launched by Finder
|
|
18299
|
+
* with `/usr/bin:/bin:/usr/sbin:/sbin` - on this machine `uv` is
|
|
18300
|
+
* `/opt/homebrew/bin/uv`. Without `installerSearchPath` the feature fails with
|
|
18301
|
+
* an `installer exited 127` that reads as an installer bug.
|
|
18302
|
+
*
|
|
18303
|
+
* The cwd is the user's home: a neutral directory, never the app's resources
|
|
18304
|
+
* dir, so an installer that resolves a relative path from where it stands
|
|
18305
|
+
* cannot land inside the sealed bundle. `shell: true` is never used - this is a
|
|
18306
|
+
* resolved shim path, not a command line.
|
|
18307
|
+
*/
|
|
18308
|
+
async runGlobalUpdate(consolePath) {
|
|
18309
|
+
const updatePath = installerSearchPath(process.env, os.homedir());
|
|
18310
|
+
logger.info(
|
|
18311
|
+
`Executing the install's own updater: ${consolePath} update (PATH: ${updatePath})`,
|
|
18312
|
+
LogFileType.UPDATE_SERVICE
|
|
18313
|
+
);
|
|
18314
|
+
const run = await runCommand(consolePath, ["update"], {
|
|
18315
|
+
timeoutMs: 15 * 60 * 1e3,
|
|
18316
|
+
env: { ...this.pythonSpawnEnv(), PATH: updatePath }
|
|
18317
|
+
});
|
|
18318
|
+
logger.info(
|
|
18319
|
+
`lop update stdout: ${run.stdout.trim() || "(empty)"}`,
|
|
18320
|
+
LogFileType.UPDATE_SERVICE
|
|
18321
|
+
);
|
|
18322
|
+
if (run.stderr.trim().length > 0) {
|
|
18323
|
+
logger.warn(
|
|
18324
|
+
`lop update stderr: ${run.stderr.trim()}`,
|
|
18325
|
+
LogFileType.UPDATE_SERVICE
|
|
18326
|
+
);
|
|
18327
|
+
}
|
|
18328
|
+
return run;
|
|
18329
|
+
}
|
|
18330
|
+
/**
|
|
18331
|
+
* True while an app-driven update of the global install is running.
|
|
18332
|
+
*
|
|
18333
|
+
* The guard is here and not on the surfaces that ask for the update, because
|
|
18334
|
+
* their flags are per-surface: the offer holds `checking` and the run panel holds
|
|
18335
|
+
* `updatingBackend`, so a press on each of them reached `updateGlobalInstall`
|
|
18336
|
+
* twice and the second `lop update` ran beside the first against one install root
|
|
18337
|
+
* (review R1-8). The bundled path never needed one because it stops the backend
|
|
18338
|
+
* first, which serialises it by accident; nothing on this path stops anything.
|
|
18339
|
+
*/
|
|
18340
|
+
globalUpdateInFlight = false;
|
|
18341
|
+
/**
|
|
18342
|
+
* Update the resolved global install, then restart the daemon this app owns.
|
|
18343
|
+
*
|
|
18344
|
+
* ORDERING IS THE POINT, and it is the opposite of the bundled path: that one
|
|
18345
|
+
* stops the backend BEFORE pip because pip replaces the environment the daemon
|
|
18346
|
+
* runs from. Under generations the install lands in a new tree, the pointer
|
|
18347
|
+
* flips, and no running process's tree is touched - so the install cannot fail
|
|
18348
|
+
* the daemon, and stopping first would throw away in-flight work for the whole
|
|
18349
|
+
* install to buy nothing. Install, verify, restart. A failed install therefore
|
|
18350
|
+
* leaves a daemon that never stopped serving.
|
|
18351
|
+
*
|
|
18352
|
+
* `landed` is a READING, not an exit code: `lop update` exits 0 in its "already
|
|
18353
|
+
* latest" case too, so the app holds the install's own version to the target
|
|
18354
|
+
* exactly as the pip path does (`didUpgradeLand`).
|
|
18355
|
+
*/
|
|
18356
|
+
async updateGlobalInstall(backend, plan, target2) {
|
|
18357
|
+
if (this.globalUpdateInFlight) {
|
|
18358
|
+
logger.warn(
|
|
18359
|
+
"A global install update is already running; refusing to start a second beside it",
|
|
18360
|
+
LogFileType.UPDATE_SERVICE
|
|
18361
|
+
);
|
|
18362
|
+
this.sendToRenderer("backend-update-error", {
|
|
18363
|
+
message: "A server update is already running. Let it finish before starting another.",
|
|
18364
|
+
phase: "update",
|
|
18365
|
+
logPath: serverUpdateLogPath()
|
|
18366
|
+
});
|
|
18367
|
+
return false;
|
|
18368
|
+
}
|
|
18369
|
+
this.globalUpdateInFlight = true;
|
|
18370
|
+
try {
|
|
18371
|
+
return await this.runGlobalUpdateAttempt(backend, plan, target2);
|
|
18372
|
+
} finally {
|
|
18373
|
+
this.globalUpdateInFlight = false;
|
|
18374
|
+
}
|
|
18375
|
+
}
|
|
18376
|
+
/** The attempt itself, under the guard above. */
|
|
18377
|
+
async runGlobalUpdateAttempt(backend, plan, target2) {
|
|
18378
|
+
const consolePath = this.resolveLocalOperatorPath();
|
|
18379
|
+
if (!consolePath) {
|
|
18380
|
+
this.sendToRenderer("backend-update-error", {
|
|
18381
|
+
message: "The global install could not be found on this machine, so the update did not run. See the update service log.",
|
|
18382
|
+
phase: "update",
|
|
18383
|
+
logPath: serverUpdateLogPath()
|
|
18384
|
+
});
|
|
18385
|
+
return false;
|
|
18386
|
+
}
|
|
18387
|
+
const before = this.readGlobalInstallVersion() ?? plan.installedInstallVersion;
|
|
18388
|
+
this.sendToRenderer("backend-update-progress", { phase: "installing" });
|
|
18389
|
+
writePendingServerUpdateMarker(this.markerDir(), {
|
|
18390
|
+
before,
|
|
18391
|
+
target: target2,
|
|
18392
|
+
startedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
18393
|
+
});
|
|
18394
|
+
const run = await this.runGlobalUpdate(consolePath);
|
|
18395
|
+
const after = this.readGlobalInstallVersion();
|
|
18396
|
+
clearPendingServerUpdateMarker(this.markerDir());
|
|
18397
|
+
const landed = run.exitCode === 0 && didUpgradeLand({ before, after, target: target2 });
|
|
18398
|
+
if (!landed) {
|
|
18399
|
+
const running = await this.getInstalledBackendVersion();
|
|
18400
|
+
const tail = stderrTail(run.stderr);
|
|
18401
|
+
logger.error(
|
|
18402
|
+
`Global install update did not land (exited ${run.exitCode}, ran ${run.ran}): ${before ?? "unknown"} -> ${after ?? "unknown"}, running backend reports ${running ?? "no reading"}`,
|
|
18403
|
+
LogFileType.UPDATE_SERVICE
|
|
18404
|
+
);
|
|
18405
|
+
this.sendToRenderer("backend-update-error", {
|
|
18406
|
+
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 ? `
|
|
18407
|
+
|
|
18408
|
+
${tail}` : ""}`,
|
|
18409
|
+
phase: "update",
|
|
18410
|
+
logPath: serverUpdateLogPath()
|
|
18411
|
+
});
|
|
18412
|
+
return false;
|
|
18413
|
+
}
|
|
18414
|
+
logger.info(
|
|
18415
|
+
`Global install updated: ${before ?? "unknown"} -> ${after ?? "unknown"}`,
|
|
18416
|
+
LogFileType.UPDATE_SERVICE
|
|
18417
|
+
);
|
|
18418
|
+
if (backend.isUsingExternalBackend()) {
|
|
18419
|
+
const running = await this.getInstalledBackendVersion();
|
|
18420
|
+
logger.info(
|
|
18421
|
+
`Install moved to ${after ?? "unknown"}; the attached backend still reports ${running ?? "no reading"}`,
|
|
18422
|
+
LogFileType.UPDATE_SERVICE
|
|
18423
|
+
);
|
|
18424
|
+
this.sendToRenderer("backend-update-completed", {
|
|
18425
|
+
installVersion: after,
|
|
18426
|
+
runningVersion: running,
|
|
18427
|
+
restarted: false,
|
|
18428
|
+
restartable: this.backendIsAppOwned()
|
|
18429
|
+
});
|
|
18430
|
+
return true;
|
|
18431
|
+
}
|
|
18432
|
+
logger.info(
|
|
18433
|
+
"Restarting backend service onto the updated install...",
|
|
18434
|
+
LogFileType.UPDATE_SERVICE
|
|
18435
|
+
);
|
|
18436
|
+
this.sendToRenderer("backend-update-progress", { phase: "restarting" });
|
|
18437
|
+
backend.setAutoUpdating(true);
|
|
18438
|
+
const restartSuccess = await backend.restart();
|
|
18439
|
+
backend.setAutoUpdating(false);
|
|
18440
|
+
if (!restartSuccess) {
|
|
18441
|
+
logger.error(
|
|
18442
|
+
"Backend service restart failed after the global install update",
|
|
18443
|
+
LogFileType.UPDATE_SERVICE
|
|
18444
|
+
);
|
|
18445
|
+
this.sendToRenderer("backend-update-error", {
|
|
18446
|
+
message: "The server was updated but the backend did not restart. Restart Local Operator to run the new version.",
|
|
18447
|
+
phase: "update",
|
|
18448
|
+
logPath: serverUpdateLogPath()
|
|
18449
|
+
});
|
|
18450
|
+
return false;
|
|
18451
|
+
}
|
|
18452
|
+
const healthy = await this.checkBackendHealth();
|
|
18453
|
+
if (!healthy) {
|
|
18454
|
+
logger.error(
|
|
18455
|
+
"Backend service restarted but the health check failed; retrying once",
|
|
18456
|
+
LogFileType.UPDATE_SERVICE
|
|
18457
|
+
);
|
|
18458
|
+
await backend.start();
|
|
18459
|
+
if (!await this.checkBackendHealth()) {
|
|
18460
|
+
this.sendToRenderer("backend-update-error", {
|
|
18461
|
+
message: "The server was updated but the backend did not restart properly. Restart Local Operator to run the new version.",
|
|
18462
|
+
phase: "update",
|
|
18463
|
+
logPath: serverUpdateLogPath()
|
|
18464
|
+
});
|
|
18465
|
+
return false;
|
|
18466
|
+
}
|
|
18467
|
+
}
|
|
18468
|
+
const reported = await this.waitForBackendVersion(target2);
|
|
18469
|
+
if (reported === null) {
|
|
18470
|
+
logger.error(
|
|
18471
|
+
`Backend restarted onto the updated install but did not report ${target2 ?? "the target version"}`,
|
|
18472
|
+
LogFileType.UPDATE_SERVICE
|
|
18473
|
+
);
|
|
18474
|
+
this.sendToRenderer("backend-update-error", {
|
|
18475
|
+
message: `The server restarted but did not report ${target2 ? `version ${target2}` : "the updated version"}. See the update service log, then restart Local Operator.`,
|
|
18476
|
+
phase: "update",
|
|
18477
|
+
logPath: serverUpdateLogPath()
|
|
18478
|
+
});
|
|
18479
|
+
return false;
|
|
18480
|
+
}
|
|
18481
|
+
logger.info(
|
|
18482
|
+
`Backend reports version ${reported} after the global install update`,
|
|
18483
|
+
LogFileType.UPDATE_SERVICE
|
|
18484
|
+
);
|
|
18485
|
+
this.sendToRenderer("backend-update-completed", {
|
|
18486
|
+
installVersion: after,
|
|
18487
|
+
runningVersion: reported,
|
|
18488
|
+
restarted: true
|
|
18489
|
+
});
|
|
18490
|
+
return true;
|
|
18491
|
+
}
|
|
18492
|
+
/**
|
|
18493
|
+
* Report a server update that landed while no app was supervising it.
|
|
18494
|
+
*
|
|
18495
|
+
* The other half of UX U6: the marker above survives exactly when the app died
|
|
18496
|
+
* mid-attempt, and the installer is a child that outlives its parent, so the
|
|
18497
|
+
* update can land with nothing watching. The end state is coherent on its own -
|
|
18498
|
+
* the app comes up on the new build and reports it - which is why this reports
|
|
18499
|
+
* the fact ONCE rather than treating a stale marker as a failure: there is no
|
|
18500
|
+
* remedy to offer and no version to hold anyone to, only a sentence the user has
|
|
18501
|
+
* never seen about an update they started.
|
|
18502
|
+
*
|
|
18503
|
+
* Cleared either way: a marker whose install did NOT move describes an
|
|
18504
|
+
* interrupted attempt whose reason is in the log, and re-reporting it on every
|
|
18505
|
+
* launch would be a permanent warning about a state the app has since read
|
|
18506
|
+
* correctly (and the failure path keeps no record for the app to re-read).
|
|
18507
|
+
*/
|
|
18508
|
+
async reportUnattendedServerUpdate() {
|
|
18509
|
+
const marker = readPendingServerUpdateMarker(this.markerDir());
|
|
18510
|
+
if (!marker) return;
|
|
18511
|
+
const after = this.readGlobalInstallVersion();
|
|
18512
|
+
clearPendingServerUpdateMarker(this.markerDir());
|
|
18513
|
+
if (!after || after === marker.before) {
|
|
18514
|
+
logger.info(
|
|
18515
|
+
`A server update started at ${marker.startedAt} did not move the install (still ${after ?? "no reading"}); nothing to report on this launch`,
|
|
18516
|
+
LogFileType.UPDATE_SERVICE
|
|
18517
|
+
);
|
|
18518
|
+
return;
|
|
18519
|
+
}
|
|
18520
|
+
const running = await this.getInstalledBackendVersion();
|
|
18521
|
+
logger.info(
|
|
18522
|
+
`A server update that no app was supervising landed: ${marker.before ?? "unknown"} -> ${after}; the backend serving this launch reports ${running ?? "no reading"}`,
|
|
18523
|
+
LogFileType.UPDATE_SERVICE
|
|
18524
|
+
);
|
|
18525
|
+
this.sendToRenderer("backend-update-completed", {
|
|
18526
|
+
installVersion: after,
|
|
18527
|
+
runningVersion: running,
|
|
18528
|
+
restarted: false,
|
|
18529
|
+
unattended: true,
|
|
18530
|
+
restartable: this.backendIsAppOwned(),
|
|
18531
|
+
before: marker.before
|
|
18532
|
+
});
|
|
18533
|
+
}
|
|
17748
18534
|
async updateBackend(targetVersion) {
|
|
17749
18535
|
logger.info("Updating backend...", LogFileType.UPDATE_SERVICE);
|
|
17750
18536
|
try {
|
|
@@ -17756,7 +18542,8 @@ ${heal.removed.join("\n")}`,
|
|
|
17756
18542
|
if (this.mainWindow && !this.mainWindow.isDestroyed() && this.mainWindow.webContents && !this.mainWindow.webContents.isDestroyed()) {
|
|
17757
18543
|
this.mainWindow.webContents.send("backend-update-error", {
|
|
17758
18544
|
message: "No backend service reference available, cannot update.",
|
|
17759
|
-
phase: "update"
|
|
18545
|
+
phase: "update",
|
|
18546
|
+
logPath: serverUpdateLogPath()
|
|
17760
18547
|
});
|
|
17761
18548
|
}
|
|
17762
18549
|
return false;
|
|
@@ -17771,13 +18558,22 @@ ${heal.removed.join("\n")}`,
|
|
|
17771
18558
|
if (this.mainWindow && !this.mainWindow.isDestroyed() && this.mainWindow.webContents && !this.mainWindow.webContents.isDestroyed()) {
|
|
17772
18559
|
this.mainWindow.webContents.send("backend-update-error", {
|
|
17773
18560
|
message: "No python server is available, nothing to update.",
|
|
17774
|
-
phase: "update"
|
|
18561
|
+
phase: "update",
|
|
18562
|
+
logPath: serverUpdateLogPath()
|
|
17775
18563
|
});
|
|
17776
18564
|
}
|
|
17777
18565
|
return false;
|
|
17778
18566
|
case LocalOperatorStartupMode.EXISTING_SERVER:
|
|
17779
18567
|
case LocalOperatorStartupMode.GLOBAL_INSTALL: {
|
|
17780
18568
|
const plan = await this.resolveBackendUpdatePlan(startupMode);
|
|
18569
|
+
const target2 = targetVersion ?? this.lastPublishedBackendVersion ?? null;
|
|
18570
|
+
if (plan.canManageUpdate) {
|
|
18571
|
+
return await this.updateGlobalInstall(
|
|
18572
|
+
this.backendService,
|
|
18573
|
+
plan,
|
|
18574
|
+
target2
|
|
18575
|
+
);
|
|
18576
|
+
}
|
|
17781
18577
|
logger.info(
|
|
17782
18578
|
`Cannot manage the update for ${startupMode}: ${plan.detail}`,
|
|
17783
18579
|
LogFileType.UPDATE_SERVICE
|
|
@@ -17787,13 +18583,10 @@ ${heal.removed.join("\n")}`,
|
|
|
17787
18583
|
message: plan.remedy,
|
|
17788
18584
|
command: plan.updateCommand,
|
|
17789
18585
|
detail: plan.detail,
|
|
17790
|
-
|
|
17791
|
-
// button does), otherwise the published release the last check
|
|
17792
|
-
// read: the compatibility banner calls this with no target at
|
|
17793
|
-
// all, and the panel's version sentence then had nothing to
|
|
17794
|
-
// render on the path that actually produces it (review U17).
|
|
17795
|
-
latestVersion: targetVersion ?? this.lastPublishedBackendVersion ?? null,
|
|
18586
|
+
latestVersion: target2,
|
|
17796
18587
|
currentVersion: installed && installed !== "Unknown" ? installed : null,
|
|
18588
|
+
installVersion: plan.installedInstallVersion,
|
|
18589
|
+
runningVersion: installed,
|
|
17797
18590
|
sourceBuild: plan.sourceBuild
|
|
17798
18591
|
});
|
|
17799
18592
|
return false;
|
|
@@ -17807,7 +18600,8 @@ ${heal.removed.join("\n")}`,
|
|
|
17807
18600
|
);
|
|
17808
18601
|
this.sendToRenderer("backend-update-error", {
|
|
17809
18602
|
message: "The app could not tell how this server was started, so it did not update it. See the update service log.",
|
|
17810
|
-
phase: "update"
|
|
18603
|
+
phase: "update",
|
|
18604
|
+
logPath: serverUpdateLogPath()
|
|
17811
18605
|
});
|
|
17812
18606
|
return false;
|
|
17813
18607
|
}
|
|
@@ -17841,7 +18635,8 @@ ${heal.removed.join("\n")}`,
|
|
|
17841
18635
|
await this.restartBackendAfterFailedUpgrade();
|
|
17842
18636
|
this.sendToRenderer("backend-update-error", {
|
|
17843
18637
|
message: "The bundled server's Python environment could not be found, so the update did not run. Please reinstall the application.",
|
|
17844
|
-
phase: "update"
|
|
18638
|
+
phase: "update",
|
|
18639
|
+
logPath: serverUpdateLogPath()
|
|
17845
18640
|
});
|
|
17846
18641
|
return false;
|
|
17847
18642
|
}
|
|
@@ -17881,7 +18676,8 @@ ${pipRun.stderr.trim()}`,
|
|
|
17881
18676
|
const serverIsBack = await this.restartBackendAfterFailedUpgrade();
|
|
17882
18677
|
this.sendToRenderer("backend-update-error", {
|
|
17883
18678
|
message: pipRun.exitCode !== 0 ? serverIsBack ? "The server update failed to install. The previously installed server is running again; see the update service log for pip's output." : "The server update failed to install and the server did not come back up. Restart Local Operator, and see the update service log for pip's output." : `The server update to ${targetVersion ?? "the new release"} did not take effect: the server is still on ${versionAfter ?? "its previous version"}. See the update service log for pip's output, then try again.`,
|
|
17884
|
-
phase: "update"
|
|
18679
|
+
phase: "update",
|
|
18680
|
+
logPath: serverUpdateLogPath()
|
|
17885
18681
|
});
|
|
17886
18682
|
return false;
|
|
17887
18683
|
}
|
|
@@ -17920,7 +18716,8 @@ ${pipRun.stderr.trim()}`,
|
|
|
17920
18716
|
if (this.mainWindow) {
|
|
17921
18717
|
this.mainWindow.webContents.send("backend-update-error", {
|
|
17922
18718
|
message: "Backend was updated but failed to restart properly. Please restart the application.",
|
|
17923
|
-
phase: "update"
|
|
18719
|
+
phase: "update",
|
|
18720
|
+
logPath: serverUpdateLogPath()
|
|
17924
18721
|
});
|
|
17925
18722
|
}
|
|
17926
18723
|
return false;
|
|
@@ -17935,7 +18732,8 @@ ${pipRun.stderr.trim()}`,
|
|
|
17935
18732
|
if (this.mainWindow) {
|
|
17936
18733
|
this.mainWindow.webContents.send("backend-update-error", {
|
|
17937
18734
|
message: "Backend was updated but failed to restart. Please restart the application.",
|
|
17938
|
-
phase: "update"
|
|
18735
|
+
phase: "update",
|
|
18736
|
+
logPath: serverUpdateLogPath()
|
|
17939
18737
|
});
|
|
17940
18738
|
}
|
|
17941
18739
|
return false;
|
|
@@ -17950,7 +18748,8 @@ ${pipRun.stderr.trim()}`,
|
|
|
17950
18748
|
);
|
|
17951
18749
|
this.sendToRenderer("backend-update-error", {
|
|
17952
18750
|
message: `The server restarted but did not report ${targetVersion ? `version ${targetVersion}` : "the updated version"}. Check the update service log, then try again.`,
|
|
17953
|
-
phase: "update"
|
|
18751
|
+
phase: "update",
|
|
18752
|
+
logPath: serverUpdateLogPath()
|
|
17954
18753
|
});
|
|
17955
18754
|
return false;
|
|
17956
18755
|
}
|
|
@@ -17992,7 +18791,8 @@ ${pipRun.stderr.trim()}`,
|
|
|
17992
18791
|
// Cleaned at the source, for the reason written on the check-phase
|
|
17993
18792
|
// report above.
|
|
17994
18793
|
message: stripErrorPrefixes(error.message),
|
|
17995
|
-
phase: "update"
|
|
18794
|
+
phase: "update",
|
|
18795
|
+
logPath: serverUpdateLogPath()
|
|
17996
18796
|
});
|
|
17997
18797
|
}
|
|
17998
18798
|
return false;
|
|
@@ -18023,7 +18823,7 @@ ${pipRun.stderr.trim()}`,
|
|
|
18023
18823
|
try {
|
|
18024
18824
|
const controller = new AbortController();
|
|
18025
18825
|
const timeoutId = setTimeout(() => controller.abort(), 3e3);
|
|
18026
|
-
const response = await fetch(`${this.
|
|
18826
|
+
const response = await fetch(`${this.liveBackendUrl()}/health`, {
|
|
18027
18827
|
method: "GET",
|
|
18028
18828
|
headers: { Accept: "application/json" },
|
|
18029
18829
|
signal: controller.signal
|
|
@@ -19729,7 +20529,7 @@ electron.app.on("window-all-closed", () => {
|
|
|
19729
20529
|
electron.app.quit();
|
|
19730
20530
|
});
|
|
19731
20531
|
const QUIT_FAILSAFE_MARGIN_MS = 5e3;
|
|
19732
|
-
const QUIT_CLEANUP_FAILSAFE_MS =
|
|
20532
|
+
const QUIT_CLEANUP_FAILSAFE_MS = INTERPRETER_RESOLUTION_WORST_MS + OWNED_STOP_WORST_MS + READINESS_POLL_INTERVAL_MS + QUIT_FAILSAFE_MARGIN_MS;
|
|
19733
20533
|
let backendQuitPending = false;
|
|
19734
20534
|
electron.app.on("will-quit", (event) => {
|
|
19735
20535
|
viewerEndpoint?.close();
|