local-operator-ui 0.25.10 → 0.25.12
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 +625 -113
- package/out/preload/index.js +23 -0
- package/out/renderer/assets/{_basePickBy-Dc7CsGGc.js → _basePickBy-BNgST6Rr.js} +1 -1
- package/out/renderer/assets/{_baseUniq-BcI4o9r1.js → _baseUniq-CNa-9k-r.js} +1 -1
- package/out/renderer/assets/{agent-details-page-Bbzz81h0.js → agent-details-page-DP47DzSl.js} +2 -2
- package/out/renderer/assets/{agent-hub-page-BehmTi1m.js → agent-hub-page-D1ZXflSQ.js} +1 -1
- package/out/renderer/assets/{agents-page-C-nkjaiF.js → agents-page-B59ShFbk.js} +2 -2
- package/out/renderer/assets/arc-BoYjpXBC.js +1 -0
- package/out/renderer/assets/{architectureDiagram-IEHRJDOE-C-aO5oDt.js → architectureDiagram-IEHRJDOE-WK5zrdxz.js} +1 -1
- package/out/renderer/assets/{blockDiagram-JOT3LUYC-BS3JLZvl.js → blockDiagram-JOT3LUYC-D9UTpO5A.js} +1 -1
- package/out/renderer/assets/browser-page-CetmvQYi.js +1 -0
- package/out/renderer/assets/{c4Diagram-VJAJSXHY-Beuab6Az.js → c4Diagram-VJAJSXHY-B3h6cXcl.js} +1 -1
- package/out/renderer/assets/channel-Ky5T3Z-m.js +1 -0
- package/out/renderer/assets/{chunk-4BMEZGHF-nHScUB-6.js → chunk-4BMEZGHF-By24kS-C.js} +1 -1
- package/out/renderer/assets/{chunk-A2AXSNBT-CpH0MbZ1.js → chunk-A2AXSNBT-BUJ4Y2e4.js} +1 -1
- package/out/renderer/assets/{chunk-AEK57VVT-B8z14xij.js → chunk-AEK57VVT-DTTsjI99.js} +1 -1
- package/out/renderer/assets/{chunk-D6G4REZN-X_olriGu.js → chunk-D6G4REZN-DvcTjmZw.js} +1 -1
- package/out/renderer/assets/{chunk-RZ5BOZE2-k05aTsX1.js → chunk-RZ5BOZE2-DXscMZJF.js} +1 -1
- package/out/renderer/assets/{chunk-XZIHB7SX-CFzOVhMD.js → chunk-XZIHB7SX-BzrqBzfD.js} +1 -1
- package/out/renderer/assets/classDiagram-GIVACNV2-g9icdFAZ.js +1 -0
- package/out/renderer/assets/classDiagram-v2-COTLJTTW-g9icdFAZ.js +1 -0
- package/out/renderer/assets/clone-BqBVBasB.js +1 -0
- package/out/renderer/assets/{compact-pagination-DcQd1Yw-.js → compact-pagination-B8_U1T-B.js} +1 -1
- package/out/renderer/assets/{dagre-OKDRZEBW-CzfJf6s3.js → dagre-OKDRZEBW-gg03eZxv.js} +1 -1
- package/out/renderer/assets/{diagram-SSKATNLV-C52lZrA0.js → diagram-SSKATNLV-DddhoXRo.js} +1 -1
- package/out/renderer/assets/{diagram-VNBRO52H-PR22VW_W.js → diagram-VNBRO52H-mKPJ5dH9.js} +1 -1
- package/out/renderer/assets/{erDiagram-Q7BY3M3F-C_GPAogs.js → erDiagram-Q7BY3M3F-ByuwRXpd.js} +1 -1
- package/out/renderer/assets/{flowDiagram-4HSFHLVR-DZnJIoPr.js → flowDiagram-4HSFHLVR-CTX-QN_S.js} +1 -1
- package/out/renderer/assets/{ganttDiagram-APWFNJXF-DQBsrrpK.js → ganttDiagram-APWFNJXF-B7oQhCsh.js} +5 -5
- package/out/renderer/assets/{gitGraphDiagram-7IBYFJ6S-AfFACac2.js → gitGraphDiagram-7IBYFJ6S-DtG8gjjj.js} +1 -1
- package/out/renderer/assets/{graph-Bjxe6B9O.js → graph-DiOqbHad.js} +1 -1
- package/out/renderer/assets/{icon-giwXJbjk.js → icon-CSpXTlbM.js} +38 -33
- package/out/renderer/assets/icon-D2QOBUv8.css +1 -0
- package/out/renderer/assets/index-BD5IRbfX.js +7 -0
- package/out/renderer/assets/{index-DOR1dMWN.js → index-T22NbVLg.js} +390 -385
- package/out/renderer/assets/{index-BHDf26BA.js → index-z8If9Hsx.js} +1 -1
- package/out/renderer/assets/{infoDiagram-PH2N3AL5-g5maBrjp.js → infoDiagram-PH2N3AL5-DzYo0O21.js} +1 -1
- package/out/renderer/assets/{installer-BQl_2uJr.js → installer-CJQ6lCyv.js} +2 -2
- package/out/renderer/assets/{journeyDiagram-U35MCT3I-BlXoyGIt.js → journeyDiagram-U35MCT3I-DFXUunVq.js} +1 -1
- package/out/renderer/assets/{kanban-definition-NDS4AKOZ-BSPXz__X.js → kanban-definition-NDS4AKOZ-Cuxd0jpB.js} +1 -1
- package/out/renderer/assets/{layout-R71oDIMJ.js → layout-jJpq7v2W.js} +1 -1
- package/out/renderer/assets/{legacy-agents-page-Dl_OPtYj.js → legacy-agents-page-BiYETwqx.js} +6 -6
- package/out/renderer/assets/{mermaid.core-CjJqe_d-.js → mermaid.core-7c_Muji_.js} +5 -5
- package/out/renderer/assets/{mindmap-definition-ALO5MXBD-BujOxX7c.js → mindmap-definition-ALO5MXBD-CFGcKAtd.js} +1 -1
- package/out/renderer/assets/{page-header-B_TbLiZC.js → page-header-mDu8ASp6.js} +1 -1
- package/out/renderer/assets/{parseISO-WLamiqnN.js → parseISO-CpkRc9R5.js} +1 -1
- package/out/renderer/assets/{pieDiagram-IB7DONF6-CHZYRhVG.js → pieDiagram-IB7DONF6-CB5F2Scj.js} +1 -1
- package/out/renderer/assets/{quadrantDiagram-7GDLP6J5-mOsQ00cE.js → quadrantDiagram-7GDLP6J5-T2ChvnfN.js} +1 -1
- package/out/renderer/assets/{radar-MK3ICKWK-C0G40Mrt.js → radar-MK3ICKWK-Dnt4pIEP.js} +1 -1
- package/out/renderer/assets/{radient-auth-buttons-BvMD0K4b.js → radient-auth-buttons-9z_hytPL.js} +1 -1
- package/out/renderer/assets/{requirementDiagram-KVF5MWMF-BYjlnn4c.js → requirementDiagram-KVF5MWMF-2LwxU0xD.js} +1 -1
- package/out/renderer/assets/{sankeyDiagram-QLVOVGJD-Bd-AbvK-.js → sankeyDiagram-QLVOVGJD-D2hSv1wN.js} +1 -1
- package/out/renderer/assets/{schedules-page-DieuF5Xp.js → schedules-page-Bzys1Cgl.js} +8 -8
- package/out/renderer/assets/{sequenceDiagram-X6HHIX6F-BcmQ0lXI.js → sequenceDiagram-X6HHIX6F-DtEsIqsS.js} +1 -1
- package/out/renderer/assets/{settings-page-DIb8gPLb.js → settings-page-3xbYrUog.js} +9 -9
- package/out/renderer/assets/{stateDiagram-DGXRK772-DDC41wRc.js → stateDiagram-DGXRK772-x2r7beBJ.js} +1 -1
- package/out/renderer/assets/stateDiagram-v2-YXO3MK2T-D2e263sO.js +1 -0
- package/out/renderer/assets/{timeline-definition-BDJGKUSR-BvNMSpcF.js → timeline-definition-BDJGKUSR-B9JtqCfm.js} +1 -1
- package/out/renderer/assets/{use-agent-like-mutation-AG1SEqKe.js → use-agent-like-mutation-D0ncsaM7.js} +1 -1
- package/out/renderer/assets/{xychartDiagram-VJFVF3MP-CrFd9Hqj.js → xychartDiagram-VJFVF3MP-CCw8vLRW.js} +1 -1
- package/out/renderer/index.html +3 -3
- package/out/renderer/installer.html +3 -4
- package/package.json +2 -2
- package/out/renderer/assets/arc-DRqvxu6i.js +0 -1
- package/out/renderer/assets/arrow-left-CiJceI8e.js +0 -6
- package/out/renderer/assets/browser-page-BNjbdOsx.js +0 -1
- package/out/renderer/assets/channel-BW4mzN_3.js +0 -1
- package/out/renderer/assets/classDiagram-GIVACNV2-I4mBQb8Y.js +0 -1
- package/out/renderer/assets/classDiagram-v2-COTLJTTW-I4mBQb8Y.js +0 -1
- package/out/renderer/assets/clone-CFuPc3b2.js +0 -1
- package/out/renderer/assets/icon-BDu7a_-6.css +0 -1
- package/out/renderer/assets/index-C8lN9NIR.js +0 -7
- package/out/renderer/assets/shield-check-BJ9wbi-7.js +0 -6
- package/out/renderer/assets/stateDiagram-v2-YXO3MK2T-tZkQuuhw.js +0 -1
package/out/main/index.js
CHANGED
|
@@ -1750,21 +1750,38 @@ function endpoint(request) {
|
|
|
1750
1750
|
};
|
|
1751
1751
|
}
|
|
1752
1752
|
}
|
|
1753
|
-
async function
|
|
1753
|
+
async function requestDesktopMediaOutcome(input, bytes, backendUrl, token) {
|
|
1754
1754
|
const parsed = mediaRequestSchema.safeParse(input);
|
|
1755
1755
|
if (!parsed.success) {
|
|
1756
|
-
return {
|
|
1756
|
+
return {
|
|
1757
|
+
response: {
|
|
1758
|
+
status: 422,
|
|
1759
|
+
kind: "error",
|
|
1760
|
+
detail: "Invalid media operation."
|
|
1761
|
+
},
|
|
1762
|
+
answered: false
|
|
1763
|
+
};
|
|
1757
1764
|
}
|
|
1758
1765
|
if (!token) {
|
|
1759
1766
|
return {
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1767
|
+
response: {
|
|
1768
|
+
status: 503,
|
|
1769
|
+
kind: "error",
|
|
1770
|
+
detail: "Restart with a desktop-managed backend to use these controls."
|
|
1771
|
+
},
|
|
1772
|
+
answered: false
|
|
1763
1773
|
};
|
|
1764
1774
|
}
|
|
1765
1775
|
const request = parsed.data;
|
|
1766
1776
|
if (bytes && bytes.byteLength > MAX_UPLOAD_BYTES) {
|
|
1767
|
-
return {
|
|
1777
|
+
return {
|
|
1778
|
+
response: {
|
|
1779
|
+
status: 413,
|
|
1780
|
+
kind: "error",
|
|
1781
|
+
detail: "This file is too large."
|
|
1782
|
+
},
|
|
1783
|
+
answered: false
|
|
1784
|
+
};
|
|
1768
1785
|
}
|
|
1769
1786
|
const target2 = endpoint(request);
|
|
1770
1787
|
let body;
|
|
@@ -1776,7 +1793,10 @@ async function requestDesktopMedia(input, bytes, backendUrl, token) {
|
|
|
1776
1793
|
contentType = "application/json";
|
|
1777
1794
|
} else {
|
|
1778
1795
|
if (!bytes) {
|
|
1779
|
-
return {
|
|
1796
|
+
return {
|
|
1797
|
+
response: { status: 422, kind: "error", detail: "A file is required." },
|
|
1798
|
+
answered: false
|
|
1799
|
+
};
|
|
1780
1800
|
}
|
|
1781
1801
|
const form = new FormData();
|
|
1782
1802
|
const blob = new Blob([bytes], {
|
|
@@ -1790,6 +1810,7 @@ async function requestDesktopMedia(input, bytes, backendUrl, token) {
|
|
|
1790
1810
|
}
|
|
1791
1811
|
body = form;
|
|
1792
1812
|
}
|
|
1813
|
+
let answered = false;
|
|
1793
1814
|
try {
|
|
1794
1815
|
const response = await fetch(new URL(target2.path, backendUrl), {
|
|
1795
1816
|
method: target2.method,
|
|
@@ -1802,6 +1823,7 @@ async function requestDesktopMedia(input, bytes, backendUrl, token) {
|
|
|
1802
1823
|
redirect: "error",
|
|
1803
1824
|
signal: AbortSignal.timeout(12e4)
|
|
1804
1825
|
});
|
|
1826
|
+
answered = true;
|
|
1805
1827
|
const responseType = response.headers.get("content-type") ?? "";
|
|
1806
1828
|
if (!response.ok) {
|
|
1807
1829
|
let detail = "The media request failed.";
|
|
@@ -1813,34 +1835,57 @@ async function requestDesktopMedia(input, bytes, backendUrl, token) {
|
|
|
1813
1835
|
} catch {
|
|
1814
1836
|
}
|
|
1815
1837
|
}
|
|
1816
|
-
return {
|
|
1838
|
+
return {
|
|
1839
|
+
response: { status: response.status, kind: "error", detail },
|
|
1840
|
+
answered: true
|
|
1841
|
+
};
|
|
1817
1842
|
}
|
|
1818
1843
|
if (responseType.includes("application/json")) {
|
|
1819
1844
|
return {
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1845
|
+
response: {
|
|
1846
|
+
status: response.status,
|
|
1847
|
+
kind: "json",
|
|
1848
|
+
body: await response.json()
|
|
1849
|
+
},
|
|
1850
|
+
answered: true
|
|
1823
1851
|
};
|
|
1824
1852
|
}
|
|
1825
1853
|
const buffer = new Uint8Array(await response.arrayBuffer());
|
|
1826
1854
|
if (buffer.byteLength > MAX_DOWNLOAD_BYTES) {
|
|
1827
1855
|
return {
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
1856
|
+
response: {
|
|
1857
|
+
status: 502,
|
|
1858
|
+
kind: "error",
|
|
1859
|
+
detail: "The backend returned too much data."
|
|
1860
|
+
},
|
|
1861
|
+
// The daemon answered; what it sent was over this relay's ceiling.
|
|
1862
|
+
answered: true
|
|
1831
1863
|
};
|
|
1832
1864
|
}
|
|
1833
1865
|
return {
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1866
|
+
response: {
|
|
1867
|
+
status: response.status,
|
|
1868
|
+
kind: "bytes",
|
|
1869
|
+
mimeType: responseType || "application/octet-stream",
|
|
1870
|
+
data: buffer
|
|
1871
|
+
},
|
|
1872
|
+
answered: true
|
|
1838
1873
|
};
|
|
1839
1874
|
} catch {
|
|
1840
1875
|
return {
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
1876
|
+
response: {
|
|
1877
|
+
status: 503,
|
|
1878
|
+
kind: "error",
|
|
1879
|
+
detail: "The backend could not complete this request. Check its connection and try again."
|
|
1880
|
+
},
|
|
1881
|
+
/*
|
|
1882
|
+
* Survives the catch for the same reason as its JSON twin, and with the same
|
|
1883
|
+
* two cases: a body that failed to parse was already `answered: true`, and so
|
|
1884
|
+
* is a failure during the body read. A timeout abort that never got a
|
|
1885
|
+
* response throws out of `fetch` first and returns `false` (review round 2
|
|
1886
|
+
* MINOR-1; review round 3 MINOR-1 found the second case).
|
|
1887
|
+
*/
|
|
1888
|
+
answered
|
|
1844
1889
|
};
|
|
1845
1890
|
}
|
|
1846
1891
|
}
|
|
@@ -2037,27 +2082,40 @@ class DesktopStreamRelay {
|
|
|
2037
2082
|
this.streams.clear();
|
|
2038
2083
|
}
|
|
2039
2084
|
}
|
|
2040
|
-
async function
|
|
2085
|
+
async function requestDesktopOutcome(input, backendUrl, token) {
|
|
2041
2086
|
const parsed = desktopRequestSchema.safeParse(input);
|
|
2042
2087
|
if (!parsed.success) {
|
|
2043
|
-
return {
|
|
2088
|
+
return {
|
|
2089
|
+
response: {
|
|
2090
|
+
status: 422,
|
|
2091
|
+
body: { detail: "Invalid desktop operation." }
|
|
2092
|
+
},
|
|
2093
|
+
answered: false
|
|
2094
|
+
};
|
|
2044
2095
|
}
|
|
2045
2096
|
const request = parsed.data;
|
|
2046
2097
|
if (request.op !== "capabilities" && !token) {
|
|
2047
2098
|
return {
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
|
|
2051
|
-
|
|
2099
|
+
response: {
|
|
2100
|
+
status: 503,
|
|
2101
|
+
body: {
|
|
2102
|
+
detail: "Restart with a desktop-managed backend to use these controls."
|
|
2103
|
+
}
|
|
2104
|
+
},
|
|
2105
|
+
answered: false
|
|
2052
2106
|
};
|
|
2053
2107
|
}
|
|
2054
2108
|
const target2 = desktopEndpoint(request);
|
|
2109
|
+
let answered = false;
|
|
2055
2110
|
try {
|
|
2056
2111
|
const body = target2.body === void 0 ? void 0 : JSON.stringify(target2.body);
|
|
2057
2112
|
if (body && Buffer.byteLength(body) > desktopRequestByteBudget(request.op)) {
|
|
2058
2113
|
return {
|
|
2059
|
-
|
|
2060
|
-
|
|
2114
|
+
response: {
|
|
2115
|
+
status: 413,
|
|
2116
|
+
body: { detail: desktopRequestTooLargeDetail(request.op) }
|
|
2117
|
+
},
|
|
2118
|
+
answered: false
|
|
2061
2119
|
};
|
|
2062
2120
|
}
|
|
2063
2121
|
const response = await fetch(new URL(target2.path, backendUrl), {
|
|
@@ -2071,20 +2129,42 @@ async function requestDesktop(input, backendUrl, token) {
|
|
|
2071
2129
|
redirect: "error",
|
|
2072
2130
|
signal: AbortSignal.timeout(2e4)
|
|
2073
2131
|
});
|
|
2132
|
+
answered = true;
|
|
2074
2133
|
const result = response.status === 204 ? null : await response.json();
|
|
2075
2134
|
if (request.op === "capabilities" && !token && result?.result) {
|
|
2076
2135
|
result.result.desktop_available = false;
|
|
2077
2136
|
}
|
|
2078
|
-
return {
|
|
2137
|
+
return {
|
|
2138
|
+
response: { status: response.status, body: result },
|
|
2139
|
+
answered: true
|
|
2140
|
+
};
|
|
2079
2141
|
} catch {
|
|
2080
2142
|
return {
|
|
2081
|
-
|
|
2082
|
-
|
|
2083
|
-
|
|
2084
|
-
|
|
2143
|
+
response: {
|
|
2144
|
+
status: 503,
|
|
2145
|
+
body: {
|
|
2146
|
+
detail: "The backend could not complete this request. Check its connection and try again."
|
|
2147
|
+
}
|
|
2148
|
+
},
|
|
2149
|
+
/*
|
|
2150
|
+
* `answered` survives the catch, and it is true here in exactly TWO cases,
|
|
2151
|
+
* both of them a listener that answered: a body this process could not
|
|
2152
|
+
* parse (the flag was set when `fetch` resolved, and a malformed answer is
|
|
2153
|
+
* still an answer), and a failure DURING the body read - an abort that
|
|
2154
|
+
* fired while the bytes were being pulled in lands here with `answered`
|
|
2155
|
+
* already set. A timeout abort that never got a response does NOT:
|
|
2156
|
+
* it throws out of `fetch` before the assignment, so it returns
|
|
2157
|
+
* `answered: false`, like a refused socket (review round 2 MINOR-1, and
|
|
2158
|
+
* review round 3 MINOR-1 which found the second case - the wording said
|
|
2159
|
+
* "the ONE case" and the invariant it named was narrower than the code).
|
|
2160
|
+
*/
|
|
2161
|
+
answered
|
|
2085
2162
|
};
|
|
2086
2163
|
}
|
|
2087
2164
|
}
|
|
2165
|
+
async function requestDesktop(input, backendUrl, token) {
|
|
2166
|
+
return (await requestDesktopOutcome(input, backendUrl, token)).response;
|
|
2167
|
+
}
|
|
2088
2168
|
function trustedDesktopFrame(actual, expected) {
|
|
2089
2169
|
try {
|
|
2090
2170
|
const current = new URL(actual);
|
|
@@ -3351,7 +3431,12 @@ function resolveGlobalInstallPlan(input) {
|
|
|
3351
3431
|
sourceBuild: false
|
|
3352
3432
|
};
|
|
3353
3433
|
}
|
|
3354
|
-
|
|
3434
|
+
const PINNABLE_VERSION_REGEX = /^(?:\d+!)?\d+(?:\.\d+)*(?:(?:a|b|rc)\d+)?(?:\.post\d+)?(?:\.dev\d+)?(?:[-+][0-9A-Za-z.]+)?$/;
|
|
3435
|
+
function isPinnableVersion(target2) {
|
|
3436
|
+
return typeof target2 === "string" && PINNABLE_VERSION_REGEX.test(target2.trim());
|
|
3437
|
+
}
|
|
3438
|
+
function buildPipUpgradeCommand(pythonPath2, targetVersion) {
|
|
3439
|
+
const requirement = isPinnableVersion(targetVersion) ? `local-operator==${targetVersion.trim()}` : "local-operator";
|
|
3355
3440
|
const args = [
|
|
3356
3441
|
"-m",
|
|
3357
3442
|
"pip",
|
|
@@ -3359,7 +3444,8 @@ function buildPipUpgradeCommand(pythonPath2) {
|
|
|
3359
3444
|
"--upgrade",
|
|
3360
3445
|
"--no-input",
|
|
3361
3446
|
"--disable-pip-version-check",
|
|
3362
|
-
"
|
|
3447
|
+
"--no-cache-dir",
|
|
3448
|
+
requirement
|
|
3363
3449
|
];
|
|
3364
3450
|
return {
|
|
3365
3451
|
command: pythonPath2,
|
|
@@ -3622,6 +3708,8 @@ const apiConfig = {
|
|
|
3622
3708
|
baseUrl: backendConfig.VITE_LOCAL_OPERATOR_API_URL
|
|
3623
3709
|
};
|
|
3624
3710
|
const DEGRADED_AFTER_FAILURES = 3;
|
|
3711
|
+
const UNANSWERED_BEFORE_DETACHED = 9;
|
|
3712
|
+
const TRANSPORT_EVIDENCE_MS = 3e4;
|
|
3625
3713
|
const PROBE_INTERVAL_MS = 1e4;
|
|
3626
3714
|
const PROBE_TIMEOUT_MS$1 = 2e3;
|
|
3627
3715
|
const DETACHED_AFTER_MS = 9e4;
|
|
@@ -3642,11 +3730,16 @@ class DaemonStateMachine {
|
|
|
3642
3730
|
updatedAt;
|
|
3643
3731
|
detachedSince = null;
|
|
3644
3732
|
backoffMs = REATTACH_BACKOFF_MS;
|
|
3733
|
+
/** Consecutive probes that ran out of budget with no answer at all. */
|
|
3734
|
+
unanswered = 0;
|
|
3735
|
+
/** When a desktop request was last answered, or null. */
|
|
3736
|
+
lastTransportAt = null;
|
|
3645
3737
|
/** Adopt a validated daemon (`owned` says whether this app spawned it). */
|
|
3646
3738
|
attach(identity, options) {
|
|
3647
3739
|
this.identity = identity;
|
|
3648
3740
|
this.owned = options.owned;
|
|
3649
3741
|
this.failures = 0;
|
|
3742
|
+
this.unanswered = 0;
|
|
3650
3743
|
this.detachedSince = null;
|
|
3651
3744
|
this.backoffMs = REATTACH_BACKOFF_MS;
|
|
3652
3745
|
this.state = "attached";
|
|
@@ -3676,6 +3769,7 @@ class DaemonStateMachine {
|
|
|
3676
3769
|
switch (observation.kind) {
|
|
3677
3770
|
case "identified":
|
|
3678
3771
|
this.failures = 0;
|
|
3772
|
+
this.unanswered = 0;
|
|
3679
3773
|
this.capabilityStatus = null;
|
|
3680
3774
|
this.detachedSince = null;
|
|
3681
3775
|
this.backoffMs = REATTACH_BACKOFF_MS;
|
|
@@ -3701,11 +3795,20 @@ class DaemonStateMachine {
|
|
|
3701
3795
|
this.enterDetached(observation.detail);
|
|
3702
3796
|
} else {
|
|
3703
3797
|
this.state = "degraded";
|
|
3704
|
-
this.detail = `${observation.detail} (probe ${this.failures} of ${DEGRADED_AFTER_FAILURES})`;
|
|
3798
|
+
this.detail = `${observation.detail} (probe ${this.failures} of ${DEGRADED_AFTER_FAILURES}).${this.transportEvidenceSuffix("so it is serving")}`;
|
|
3799
|
+
}
|
|
3800
|
+
break;
|
|
3801
|
+
case "unanswered":
|
|
3802
|
+
this.observeUnanswered(observation.detail);
|
|
3803
|
+
break;
|
|
3804
|
+
case "unattachable":
|
|
3805
|
+
if (this.state !== "attached" && this.state !== "replaced") {
|
|
3806
|
+
this.state = "wedged";
|
|
3705
3807
|
}
|
|
3808
|
+
this.detail = observation.detail;
|
|
3706
3809
|
break;
|
|
3707
3810
|
case "pid-dead":
|
|
3708
|
-
this.enterDetached("The daemon's process is gone.");
|
|
3811
|
+
this.enterDetached("The daemon's process is gone.", true);
|
|
3709
3812
|
break;
|
|
3710
3813
|
case "no-candidate":
|
|
3711
3814
|
this.enterDetached(observation.detail);
|
|
@@ -3714,7 +3817,96 @@ class DaemonStateMachine {
|
|
|
3714
3817
|
this.updatedAt = this.now();
|
|
3715
3818
|
return this.state;
|
|
3716
3819
|
}
|
|
3717
|
-
|
|
3820
|
+
/**
|
|
3821
|
+
* Fold a probe that ran out of budget with no answer.
|
|
3822
|
+
*
|
|
3823
|
+
* Three rules, in order, and each one exists because the alternative was
|
|
3824
|
+
* measured on the operator's machine:
|
|
3825
|
+
*
|
|
3826
|
+
* 1. A successful desktop request inside the evidence window means the
|
|
3827
|
+
* connection is ALIVE and the probe is the thing that is wrong. The state
|
|
3828
|
+
* does not move at all - not to `degraded`, because the app is talking to
|
|
3829
|
+
* its daemon right now - and the detail records both facts. This is the
|
|
3830
|
+
* rule that makes "no surface may say offline while the app is still
|
|
3831
|
+
* reading from it" true by construction rather than by each surface
|
|
3832
|
+
* remembering to check a second flag.
|
|
3833
|
+
* 2. Without that evidence, misses accumulate as `degraded` (usable), which
|
|
3834
|
+
* is all a 2 s budget on a busy box justifies.
|
|
3835
|
+
* 3. Only `UNANSWERED_BEFORE_DETACHED` consecutive misses detach, and the
|
|
3836
|
+
* detail says how many were counted, so the log line names the evidence.
|
|
3837
|
+
*/
|
|
3838
|
+
observeUnanswered(detail) {
|
|
3839
|
+
this.unanswered += 1;
|
|
3840
|
+
if (this.transportEvidenceFresh()) {
|
|
3841
|
+
this.detail = `${detail}${this.transportEvidenceSuffix("so it is serving and the probe's budget is what expired")} (no answer to probe ${this.unanswered}).`;
|
|
3842
|
+
return;
|
|
3843
|
+
}
|
|
3844
|
+
if (this.unanswered >= UNANSWERED_BEFORE_DETACHED) {
|
|
3845
|
+
this.enterDetached(detail);
|
|
3846
|
+
return;
|
|
3847
|
+
}
|
|
3848
|
+
this.state = "degraded";
|
|
3849
|
+
this.detail = `${detail} (no answer to probe ${this.unanswered} of ${UNANSWERED_BEFORE_DETACHED})`;
|
|
3850
|
+
}
|
|
3851
|
+
/**
|
|
3852
|
+
* Record that a real request against the attached daemon was answered.
|
|
3853
|
+
*
|
|
3854
|
+
* WHY this is the state machine's business and not a caller's: the probe and
|
|
3855
|
+
* the transport disagree regularly on a box running long turns - the probe
|
|
3856
|
+
* has a 2 s budget, a session list has a 30 s one - and the one carrying the
|
|
3857
|
+
* user's data is the transport. Recording it here is what lets
|
|
3858
|
+
* {@link observeUnanswered} prefer it without every surface re-deriving the
|
|
3859
|
+
* same fact.
|
|
3860
|
+
*
|
|
3861
|
+
* It revives `degraded` (the daemon just answered, so the missed probes are
|
|
3862
|
+
* explained) and never a `detached` connection: coming back from those is
|
|
3863
|
+
* `recoverFromDetachment`'s job, because that path also has to re-prove the
|
|
3864
|
+
* daemon's IDENTITY before anything may use it.
|
|
3865
|
+
*
|
|
3866
|
+
* @returns whether the state actually moved, so the caller only pushes a
|
|
3867
|
+
* snapshot when there is something new to push.
|
|
3868
|
+
*/
|
|
3869
|
+
recordTransportSuccess() {
|
|
3870
|
+
const moved = this.state === "degraded";
|
|
3871
|
+
this.lastTransportAt = this.now();
|
|
3872
|
+
this.unanswered = 0;
|
|
3873
|
+
if (moved) {
|
|
3874
|
+
this.failures = 0;
|
|
3875
|
+
this.state = this.identity ? "attached" : "connecting";
|
|
3876
|
+
this.detail = this.identity ? `Connected to the daemon on ${this.identity.url} (pid ${this.identity.pid}, v${this.identity.version}).` : "Connected to the Local Operator daemon.";
|
|
3877
|
+
}
|
|
3878
|
+
this.updatedAt = this.now();
|
|
3879
|
+
return moved;
|
|
3880
|
+
}
|
|
3881
|
+
/** Whether a successful request is recent enough to outrank a probe. */
|
|
3882
|
+
transportEvidenceFresh() {
|
|
3883
|
+
return this.lastTransportAt !== null && this.now() - this.lastTransportAt <= TRANSPORT_EVIDENCE_MS;
|
|
3884
|
+
}
|
|
3885
|
+
/**
|
|
3886
|
+
* The clause a sentence owes when THIS app's own request is the evidence
|
|
3887
|
+
* behind a connection the app has not detached from, or the empty string when
|
|
3888
|
+
* there is no such evidence.
|
|
3889
|
+
*
|
|
3890
|
+
* Shared rather than copied: three arms carry it (a probe budget that expired,
|
|
3891
|
+
* a refusal past the detach threshold, and a refusal short of it), and a third
|
|
3892
|
+
* copy is how one of them came to say "refused the connection" while the app
|
|
3893
|
+
* was still holding the connection open on the strength of its own answered
|
|
3894
|
+
* request.
|
|
3895
|
+
*/
|
|
3896
|
+
transportEvidenceSuffix(tail) {
|
|
3897
|
+
if (!this.transportEvidenceFresh()) return "";
|
|
3898
|
+
const silentFor = Math.round(
|
|
3899
|
+
(this.now() - this.lastTransportAt) / 1e3
|
|
3900
|
+
);
|
|
3901
|
+
return ` The daemon answered a request ${silentFor}s ago, ${tail}.`;
|
|
3902
|
+
}
|
|
3903
|
+
enterDetached(detail, corroborated = false) {
|
|
3904
|
+
if (!corroborated && this.transportEvidenceFresh()) {
|
|
3905
|
+
this.failures = 0;
|
|
3906
|
+
this.state = "degraded";
|
|
3907
|
+
this.detail = `${detail}${this.transportEvidenceSuffix("so this app is not reporting it as gone")}`;
|
|
3908
|
+
return;
|
|
3909
|
+
}
|
|
3718
3910
|
this.failures = DEGRADED_AFTER_FAILURES;
|
|
3719
3911
|
this.state = "detached";
|
|
3720
3912
|
if (this.detachedSince === null) this.detachedSince = this.now();
|
|
@@ -3770,6 +3962,13 @@ class DaemonStateMachine {
|
|
|
3770
3962
|
desktopAvailable: this.desktopAvailable,
|
|
3771
3963
|
failures: this.failures,
|
|
3772
3964
|
capabilityStatus: this.capabilityStatus,
|
|
3965
|
+
/**
|
|
3966
|
+
* Carried so a surface can explain a `degraded` connection in the same
|
|
3967
|
+
* terms main used, rather than inventing a second reason for it. Both are
|
|
3968
|
+
* also what a reader needs to tell a busy daemon from a gone one.
|
|
3969
|
+
*/
|
|
3970
|
+
unanswered: this.unanswered,
|
|
3971
|
+
lastTransportAt: this.lastTransportAt,
|
|
3773
3972
|
detail: this.detail,
|
|
3774
3973
|
updatedAt: this.updatedAt
|
|
3775
3974
|
};
|
|
@@ -3902,6 +4101,24 @@ function recordAddress(record) {
|
|
|
3902
4101
|
const host = record.host.includes(":") ? `[${record.host}]` : record.host;
|
|
3903
4102
|
return `http://${host}:${record.port}`;
|
|
3904
4103
|
}
|
|
4104
|
+
function classifyUnreachable(error) {
|
|
4105
|
+
const name = error?.name;
|
|
4106
|
+
if (name === "TimeoutError" || name === "AbortError") return "timeout";
|
|
4107
|
+
const code = error?.code ?? error?.cause?.code;
|
|
4108
|
+
switch (code) {
|
|
4109
|
+
case "ECONNREFUSED":
|
|
4110
|
+
case "EHOSTUNREACH":
|
|
4111
|
+
case "ENETUNREACH":
|
|
4112
|
+
case "ENETDOWN":
|
|
4113
|
+
case "EPIPE":
|
|
4114
|
+
return "refused";
|
|
4115
|
+
case "ENOTFOUND":
|
|
4116
|
+
case "EAI_AGAIN":
|
|
4117
|
+
return "unresolved";
|
|
4118
|
+
default:
|
|
4119
|
+
return "other";
|
|
4120
|
+
}
|
|
4121
|
+
}
|
|
3905
4122
|
function readIdentity(payload) {
|
|
3906
4123
|
if (typeof payload !== "object" || payload === null) return null;
|
|
3907
4124
|
const result = payload.result;
|
|
@@ -3958,6 +4175,7 @@ async function probeIdentity(address, expectedInstanceId, options = {}) {
|
|
|
3958
4175
|
} catch (error) {
|
|
3959
4176
|
return {
|
|
3960
4177
|
outcome: "unreachable",
|
|
4178
|
+
cause: classifyUnreachable(error),
|
|
3961
4179
|
detail: error instanceof Error ? error.message : String(error)
|
|
3962
4180
|
};
|
|
3963
4181
|
}
|
|
@@ -4143,14 +4361,16 @@ async function probeUnidentified(address, options = {}) {
|
|
|
4143
4361
|
});
|
|
4144
4362
|
if (response.status !== 200) {
|
|
4145
4363
|
return {
|
|
4146
|
-
reason: "
|
|
4364
|
+
reason: "unready-answer",
|
|
4147
4365
|
detail: `${HEALTH_PATH$1} answered ${response.status} and no record describes this address`
|
|
4148
4366
|
};
|
|
4149
4367
|
}
|
|
4150
4368
|
const payload = await response.json().catch(() => null);
|
|
4151
|
-
|
|
4369
|
+
const identity = readIdentity(payload);
|
|
4370
|
+
if (identity) {
|
|
4152
4371
|
return {
|
|
4153
4372
|
reason: "identity-mismatch",
|
|
4373
|
+
identity,
|
|
4154
4374
|
detail: `a daemon answered but no serve record describes ${address}, so it cannot be proven to be the one this app found`
|
|
4155
4375
|
};
|
|
4156
4376
|
}
|
|
@@ -4161,6 +4381,7 @@ async function probeUnidentified(address, options = {}) {
|
|
|
4161
4381
|
} catch (error) {
|
|
4162
4382
|
return {
|
|
4163
4383
|
reason: "unreachable",
|
|
4384
|
+
cause: classifyUnreachable(error),
|
|
4164
4385
|
detail: error instanceof Error ? error.message : String(error)
|
|
4165
4386
|
};
|
|
4166
4387
|
}
|
|
@@ -5189,9 +5410,15 @@ const SHUTDOWN_TIMEOUT_DEFAULTS = {
|
|
|
5189
5410
|
};
|
|
5190
5411
|
const OWNED_STOP_WORST_MS = SHUTDOWN_TIMEOUT_DEFAULTS.normal + SHUTDOWN_TIMEOUT_DEFAULTS.force;
|
|
5191
5412
|
const READINESS_POLL_INTERVAL_MS = 1e3;
|
|
5413
|
+
const DESKTOP_TOKEN_FILENAME = "desktop-token";
|
|
5192
5414
|
const execPromise = node_util.promisify(node_child_process.exec);
|
|
5193
5415
|
const ENV_VAR_REGEX = /^([^=]+)=(.*)$/;
|
|
5194
5416
|
const LINE_BREAK = /\r?\n/;
|
|
5417
|
+
function classifyDesktopAnswer(status2) {
|
|
5418
|
+
if (status2 >= 200 && status2 < 300) return "accepted";
|
|
5419
|
+
if (status2 === 401 || status2 === 403) return "refused";
|
|
5420
|
+
return "unusable";
|
|
5421
|
+
}
|
|
5195
5422
|
class BackendServiceManager {
|
|
5196
5423
|
process = null;
|
|
5197
5424
|
isRunning = false;
|
|
@@ -5215,6 +5442,19 @@ class BackendServiceManager {
|
|
|
5215
5442
|
remoteConfigured = false;
|
|
5216
5443
|
/** A failed probe or unreadable record is not evidence that spawning is safe. */
|
|
5217
5444
|
discoveryBlocksSpawn = false;
|
|
5445
|
+
/**
|
|
5446
|
+
* The address of a daemon that ANSWERED this app's desktop read without
|
|
5447
|
+
* refusing its credential, and the status it answered, from the last
|
|
5448
|
+
* discovery sweep.
|
|
5449
|
+
*
|
|
5450
|
+
* Non-null means the address is OCCUPIED by a daemon this app may not attach
|
|
5451
|
+
* to *yet*: `start()`'s no-spawn report has to publish that as a running
|
|
5452
|
+
* daemon, because the alternative it would otherwise reach - `no-candidate` -
|
|
5453
|
+
* renders as offline, which is the one sentence this whole change exists to
|
|
5454
|
+
* stop saying about a server that answers (#1170: an unreadable session store
|
|
5455
|
+
* answers 503 instead of an empty 200).
|
|
5456
|
+
*/
|
|
5457
|
+
answeredButUnusable = null;
|
|
5218
5458
|
/**
|
|
5219
5459
|
* Records discovery found alive but unresponsive (pid alive, heartbeat
|
|
5220
5460
|
* stopped). They are why no candidate exists AND why spawning is forbidden,
|
|
@@ -5226,6 +5466,14 @@ class BackendServiceManager {
|
|
|
5226
5466
|
nextRecoveryAt = 0;
|
|
5227
5467
|
appDataPath = electron.app.getPath("userData");
|
|
5228
5468
|
venvPath;
|
|
5469
|
+
/**
|
|
5470
|
+
* Why the last `/health` read did not return 200, for the callers that must
|
|
5471
|
+
* tell a refusal from an expired budget: `null` when it was not a transport
|
|
5472
|
+
* failure at all (the address answered, with a status this path does not
|
|
5473
|
+
* accept).
|
|
5474
|
+
*/
|
|
5475
|
+
lastHealthFailure = null;
|
|
5476
|
+
/** The one probe loop's interval handle. */
|
|
5229
5477
|
healthCheckInterval = null;
|
|
5230
5478
|
/** The only process this manager may terminate. Null means it owns nothing,
|
|
5231
5479
|
* which is a reason to report "nothing to stop" - never to go looking. */
|
|
@@ -5255,6 +5503,29 @@ class BackendServiceManager {
|
|
|
5255
5503
|
shellEnv = {};
|
|
5256
5504
|
// External/dev backends may be explicitly paired through main's environment.
|
|
5257
5505
|
// Managed starts always rotate this; it is never exposed by preload or logs.
|
|
5506
|
+
/**
|
|
5507
|
+
* The credential this app holds for its OWN daemon.
|
|
5508
|
+
*
|
|
5509
|
+
* Read from disk at construction, and REPLACED by a freshly minted, persisted
|
|
5510
|
+
* one on every managed start. Those are two halves of one rule:
|
|
5511
|
+
*
|
|
5512
|
+
* - the read is what makes a daemon the previous run left running
|
|
5513
|
+
* attachable. The app deliberately leaves its daemon serving when it
|
|
5514
|
+
* attached to an external backend, and a daemon it spawned is
|
|
5515
|
+
* env-governed from the spawn (its record publishes `claim_key: ""`), so
|
|
5516
|
+
* the ONLY credential that can ever open it is the token its spawner held.
|
|
5517
|
+
* A launch that starts with no token there declines the daemon it owns and
|
|
5518
|
+
* starts a second one onto its port - measured on the operator's machine as
|
|
5519
|
+
* the 07:47 -> 09:17 sequence, ending in `[Errno 48] Address already in
|
|
5520
|
+
* use`;
|
|
5521
|
+
* - the write is why the token still changes when the app REPLACES a daemon
|
|
5522
|
+
* in place: the relay's cache key is this value (see `getStreamRelay`), and
|
|
5523
|
+
* a token that survived a restart would leave a subscription pointed at a
|
|
5524
|
+
* daemon this app had already stopped.
|
|
5525
|
+
*
|
|
5526
|
+
* The environment still wins, because an explicitly paired app is a deliberate
|
|
5527
|
+
* configuration and not a cache to be second-guessed.
|
|
5528
|
+
*/
|
|
5258
5529
|
desktopToken = process.env.LOCAL_OPERATOR_DESKTOP_TOKEN || null;
|
|
5259
5530
|
/** Authenticated SSE relay for canonical session events. Recreated when the
|
|
5260
5531
|
* backend URL rotates (external-backend discovery) or the desktop token does
|
|
@@ -5447,15 +5718,51 @@ class BackendServiceManager {
|
|
|
5447
5718
|
this.feedRelay?.watchState(stateObserver);
|
|
5448
5719
|
}
|
|
5449
5720
|
requestDesktop(input) {
|
|
5450
|
-
return
|
|
5721
|
+
return requestDesktopOutcome(
|
|
5722
|
+
input,
|
|
5723
|
+
this.backendUrl,
|
|
5724
|
+
this.desktopToken
|
|
5725
|
+
).then(({ response, answered }) => {
|
|
5726
|
+
if (answered) this.noteTransportAnswer();
|
|
5727
|
+
return response;
|
|
5728
|
+
});
|
|
5451
5729
|
}
|
|
5452
5730
|
requestDesktopMedia(input, bytes) {
|
|
5453
|
-
return
|
|
5731
|
+
return requestDesktopMediaOutcome(
|
|
5454
5732
|
input,
|
|
5455
5733
|
bytes,
|
|
5456
5734
|
this.backendUrl,
|
|
5457
5735
|
this.desktopToken
|
|
5458
|
-
)
|
|
5736
|
+
).then(({ response, answered }) => {
|
|
5737
|
+
if (answered) this.noteTransportAnswer();
|
|
5738
|
+
return response;
|
|
5739
|
+
});
|
|
5740
|
+
}
|
|
5741
|
+
/**
|
|
5742
|
+
* Tell the state machine that this app's own request reached the daemon.
|
|
5743
|
+
*
|
|
5744
|
+
* `answered` is the TRANSPORT's verdict, not the promise's: every failure the
|
|
5745
|
+
* transport can produce resolves rather than throws, so `.then()` alone fires
|
|
5746
|
+
* for a refused socket, for a request this app never sent (no token, oversize,
|
|
5747
|
+
* an invalid op), and for one whose budget expired. Gating on it here is the
|
|
5748
|
+
* fix for review round 1's F-1, where an unearned stamp held the state at
|
|
5749
|
+
* `degraded` - which is not a state `checkBackendHealth` recovers from, so a
|
|
5750
|
+
* genuinely gone daemon could never be reported as gone.
|
|
5751
|
+
*
|
|
5752
|
+
* WHY this still counts EVERY answer, including a gated route's `401`/`403`:
|
|
5753
|
+
* the capability rules elsewhere in this file say a refusal is not a LIVENESS
|
|
5754
|
+
* signal, and that is about not calling it "connected". Turned around, a
|
|
5755
|
+
* refusal is the strongest liveness evidence this app has - a process read the
|
|
5756
|
+
* request and wrote a status line - which is exactly what a probe that ran out
|
|
5757
|
+
* of its 2 s budget failed to establish. Without this, one long agent turn was
|
|
5758
|
+
* enough to detach the connection and disable every read the daemon was still
|
|
5759
|
+
* answering.
|
|
5760
|
+
*
|
|
5761
|
+
* Only a state that actually moves pushes a snapshot: this runs on every
|
|
5762
|
+
* desktop call, and one IPC wake-up per request would be worse than the bug.
|
|
5763
|
+
*/
|
|
5764
|
+
noteTransportAnswer() {
|
|
5765
|
+
if (this.daemonState.recordTransportSuccess()) this.notifyStatus();
|
|
5459
5766
|
}
|
|
5460
5767
|
isAppClosing = false;
|
|
5461
5768
|
// Flag to track when the app is being closed
|
|
@@ -5494,6 +5801,7 @@ class BackendServiceManager {
|
|
|
5494
5801
|
packaged: electron.app.isPackaged
|
|
5495
5802
|
});
|
|
5496
5803
|
this.loadShellEnvironment();
|
|
5804
|
+
this.desktopToken = this.desktopToken || this.persistedDesktopToken();
|
|
5497
5805
|
logger.info(
|
|
5498
5806
|
`Backend Service Manager initialized. May spawn or kill: ${this.managerMaySpawn}`,
|
|
5499
5807
|
LogFileType.BACKEND
|
|
@@ -5768,6 +6076,11 @@ class BackendServiceManager {
|
|
|
5768
6076
|
kind: "heartbeat-stale",
|
|
5769
6077
|
detail: `A Local Operator daemon is running (pid ${wedged.pid}), but it stopped publishing its heartbeat, so this app did not attach to it. Waiting without starting a second one.`
|
|
5770
6078
|
});
|
|
6079
|
+
} else if (this.answeredButUnusable) {
|
|
6080
|
+
this.daemonState.observe({
|
|
6081
|
+
kind: "unattachable",
|
|
6082
|
+
detail: `A Local Operator daemon is running at ${this.answeredButUnusable.address} and answered this app's read with HTTP ${this.answeredButUnusable.status}, so this app is not attached to it. Nothing is being started over it; it keeps probing.`
|
|
6083
|
+
});
|
|
5771
6084
|
} else {
|
|
5772
6085
|
this.daemonState.observe({
|
|
5773
6086
|
kind: "no-candidate",
|
|
@@ -5914,6 +6227,7 @@ class BackendServiceManager {
|
|
|
5914
6227
|
async adoptFirstUsableDaemon() {
|
|
5915
6228
|
if (this.isAppClosing) return false;
|
|
5916
6229
|
this.discoveryWedged = [];
|
|
6230
|
+
this.answeredButUnusable = null;
|
|
5917
6231
|
if (this.remoteConfigured) {
|
|
5918
6232
|
this.discoveryBlocksSpawn = true;
|
|
5919
6233
|
return await this.legacyFixedPortAdoption();
|
|
@@ -5996,19 +6310,40 @@ class BackendServiceManager {
|
|
|
5996
6310
|
return false;
|
|
5997
6311
|
}
|
|
5998
6312
|
}
|
|
5999
|
-
|
|
6313
|
+
const probe = await this.probeCandidate(candidate.address, token);
|
|
6314
|
+
if (probe.verdict === "refused") {
|
|
6000
6315
|
logger.info(
|
|
6001
|
-
`Daemon ${candidate.address}
|
|
6316
|
+
`Daemon ${candidate.address} refused this app's bearer for its desktop plane (HTTP ${probe.status}); not attaching (it would refuse every session list and every stream).`,
|
|
6002
6317
|
LogFileType.BACKEND
|
|
6003
6318
|
);
|
|
6004
6319
|
this.daemonState.observe({
|
|
6005
6320
|
kind: "capability",
|
|
6006
|
-
status:
|
|
6007
|
-
detail: `A daemon is running at ${candidate.address}, but it refused this app's credential for its desktop plane.`
|
|
6321
|
+
status: probe.status,
|
|
6322
|
+
detail: `A daemon is running at ${candidate.address}, but it refused this app's credential for its desktop plane (HTTP ${probe.status}).`
|
|
6008
6323
|
});
|
|
6009
6324
|
this.notifyStatus();
|
|
6010
6325
|
return false;
|
|
6011
6326
|
}
|
|
6327
|
+
if (probe.verdict === "unusable") {
|
|
6328
|
+
logger.info(
|
|
6329
|
+
`Daemon ${candidate.address} answered HTTP ${probe.status} to this app's desktop read without refusing its credential; not attaching this tick, and not starting a daemon over it.`,
|
|
6330
|
+
LogFileType.BACKEND
|
|
6331
|
+
);
|
|
6332
|
+
this.discoveryBlocksSpawn = true;
|
|
6333
|
+
this.answeredButUnusable = {
|
|
6334
|
+
address: candidate.address,
|
|
6335
|
+
status: probe.status
|
|
6336
|
+
};
|
|
6337
|
+
this.daemonState.observe({
|
|
6338
|
+
kind: "unattachable",
|
|
6339
|
+
detail: `A daemon is running at ${candidate.address} and answered this app's read with HTTP ${probe.status}, so this app is not attached to it. Nothing is being started over it.`
|
|
6340
|
+
});
|
|
6341
|
+
this.notifyStatus();
|
|
6342
|
+
return false;
|
|
6343
|
+
}
|
|
6344
|
+
if (probe.verdict === "unreachable") {
|
|
6345
|
+
return false;
|
|
6346
|
+
}
|
|
6012
6347
|
await this.attachTo(candidate, token);
|
|
6013
6348
|
return true;
|
|
6014
6349
|
}
|
|
@@ -6024,16 +6359,17 @@ class BackendServiceManager {
|
|
|
6024
6359
|
* `this.desktopToken` because this runs BEFORE adoption: the candidate must
|
|
6025
6360
|
* be proved usable without the manager having committed to it.
|
|
6026
6361
|
*/
|
|
6027
|
-
async
|
|
6362
|
+
async probeCandidate(address, token) {
|
|
6028
6363
|
try {
|
|
6029
6364
|
const result = await requestDesktop(
|
|
6030
6365
|
{ op: "sessions.list", limit: 1 },
|
|
6031
6366
|
address,
|
|
6032
6367
|
token
|
|
6033
6368
|
);
|
|
6034
|
-
|
|
6369
|
+
const verdict = classifyDesktopAnswer(result.status);
|
|
6370
|
+
return verdict === "accepted" ? { verdict } : { verdict, status: result.status };
|
|
6035
6371
|
} catch {
|
|
6036
|
-
return
|
|
6372
|
+
return { verdict: "unreachable" };
|
|
6037
6373
|
}
|
|
6038
6374
|
}
|
|
6039
6375
|
/**
|
|
@@ -6139,7 +6475,7 @@ class BackendServiceManager {
|
|
|
6139
6475
|
const payload = await response.json().catch(() => null);
|
|
6140
6476
|
if (!await this.authenticatesAgainstBackend()) {
|
|
6141
6477
|
logger.info(
|
|
6142
|
-
"A backend answered health but
|
|
6478
|
+
"A backend answered health but did not accept this app's desktop credential; not adopting it.",
|
|
6143
6479
|
LogFileType.BACKEND
|
|
6144
6480
|
);
|
|
6145
6481
|
return false;
|
|
@@ -6187,7 +6523,7 @@ class BackendServiceManager {
|
|
|
6187
6523
|
op: "sessions.list",
|
|
6188
6524
|
limit: 1
|
|
6189
6525
|
});
|
|
6190
|
-
return result.status
|
|
6526
|
+
return classifyDesktopAnswer(result.status) === "accepted";
|
|
6191
6527
|
} catch {
|
|
6192
6528
|
return false;
|
|
6193
6529
|
}
|
|
@@ -6268,7 +6604,13 @@ class BackendServiceManager {
|
|
|
6268
6604
|
this.startHealthCheck();
|
|
6269
6605
|
return false;
|
|
6270
6606
|
}
|
|
6271
|
-
|
|
6607
|
+
const occupancy = await this.configuredOriginOccupancy();
|
|
6608
|
+
if (occupancy) {
|
|
6609
|
+
this.observeOriginOccupancy(occupancy);
|
|
6610
|
+
this.startHealthCheck();
|
|
6611
|
+
return false;
|
|
6612
|
+
}
|
|
6613
|
+
this.desktopToken = this.mintDesktopToken();
|
|
6272
6614
|
let captured = null;
|
|
6273
6615
|
try {
|
|
6274
6616
|
this.venvPath = managedVenvPath({
|
|
@@ -6700,11 +7042,142 @@ class BackendServiceManager {
|
|
|
6700
7042
|
`Backend health check response status: ${response.status}`,
|
|
6701
7043
|
LogFileType.BACKEND
|
|
6702
7044
|
);
|
|
7045
|
+
this.lastHealthFailure = null;
|
|
6703
7046
|
return response.ok;
|
|
6704
|
-
} catch (
|
|
7047
|
+
} catch (error) {
|
|
7048
|
+
this.lastHealthFailure = classifyUnreachable(error);
|
|
6705
7049
|
return false;
|
|
6706
7050
|
}
|
|
6707
7051
|
}
|
|
7052
|
+
/**
|
|
7053
|
+
* What is answering the address this app is configured to serve on, if
|
|
7054
|
+
* anything that stops it starting a daemon there.
|
|
7055
|
+
*
|
|
7056
|
+
* `null` means "start it": either the socket was refused (the one answer that
|
|
7057
|
+
* proves a port free), or something answered 200 without identifying as a Local
|
|
7058
|
+
* Operator daemon. The second arm is deliberate and narrow - see the spawn gate
|
|
7059
|
+
* in `startOwned()` - and the observation is logged either way. An answer whose
|
|
7060
|
+
* STATUS is not 200 is not in that arm: it is an occupant (review round 1,
|
|
7061
|
+
* F-2), and it returns a `silent` occupancy below rather than a licence to
|
|
7062
|
+
* spawn.
|
|
7063
|
+
*/
|
|
7064
|
+
async configuredOriginOccupancy() {
|
|
7065
|
+
const probe = await probeUnidentified(this.backendUrl, {
|
|
7066
|
+
timeoutMs: PROBE_TIMEOUT_MS$1
|
|
7067
|
+
});
|
|
7068
|
+
switch (probe.reason) {
|
|
7069
|
+
case "identity-mismatch":
|
|
7070
|
+
return {
|
|
7071
|
+
kind: "daemon",
|
|
7072
|
+
pid: probe.identity?.pid ?? null,
|
|
7073
|
+
version: probe.identity?.version ?? "",
|
|
7074
|
+
detail: probe.detail
|
|
7075
|
+
};
|
|
7076
|
+
/*
|
|
7077
|
+
* A status that is not 200 is an OCCUPANT, not a licence to spawn, and it
|
|
7078
|
+
* is handled here rather than by the `default` arm below so the decision is
|
|
7079
|
+
* the one a reader finds rather than the one the switch happens to fall
|
|
7080
|
+
* into (review round 1, F-2). It covers a Local Operator daemon starting up,
|
|
7081
|
+
* unhealthy or shutting down, and a proxy that fronts one and answers 5xx:
|
|
7082
|
+
* in every one of them the socket is bound, so a child spawned onto it dies
|
|
7083
|
+
* on `[Errno 48]` - after `mintDesktopToken()` has already overwritten the
|
|
7084
|
+
* credential for the daemon actually serving there. The answer does not
|
|
7085
|
+
* prove a daemon this app may attach to, so the gate declines to start one
|
|
7086
|
+
* and keeps probing; that is the safe direction, because the failure mode of
|
|
7087
|
+
* the other one is unrecoverable loss of the pairing token.
|
|
7088
|
+
*/
|
|
7089
|
+
case "unready-answer":
|
|
7090
|
+
return {
|
|
7091
|
+
kind: "silent",
|
|
7092
|
+
cause: "other",
|
|
7093
|
+
detail: probe.detail
|
|
7094
|
+
};
|
|
7095
|
+
case "not-a-daemon":
|
|
7096
|
+
logger.info(
|
|
7097
|
+
`${this.backendUrl}${HEALTH_PATH$1} answered and is not a Local Operator daemon (${probe.detail}); starting a daemon anyway, as this app always has.`,
|
|
7098
|
+
LogFileType.BACKEND
|
|
7099
|
+
);
|
|
7100
|
+
return null;
|
|
7101
|
+
case "unreachable":
|
|
7102
|
+
return probe.cause === "refused" ? null : {
|
|
7103
|
+
kind: "silent",
|
|
7104
|
+
cause: probe.cause ?? "other",
|
|
7105
|
+
detail: probe.detail
|
|
7106
|
+
};
|
|
7107
|
+
default:
|
|
7108
|
+
return {
|
|
7109
|
+
kind: "silent",
|
|
7110
|
+
cause: "other",
|
|
7111
|
+
detail: probe.detail
|
|
7112
|
+
};
|
|
7113
|
+
}
|
|
7114
|
+
}
|
|
7115
|
+
/**
|
|
7116
|
+
* Publish what the spawn gate saw, in the vocabulary the copy already uses.
|
|
7117
|
+
*
|
|
7118
|
+
* Two outcomes reach here and each gets the state that names it: a Local
|
|
7119
|
+
* Operator daemon this app may not drive is `unattachable` (state `wedged` - a
|
|
7120
|
+
* server IS running and this app did not attach to it, no banner claiming it is
|
|
7121
|
+
* offline), and an address that did not answer in time is `unanswered` -
|
|
7122
|
+
* `degraded`, usable, no banner, and still counted, because a budget that
|
|
7123
|
+
* expired is not evidence of absence either way.
|
|
7124
|
+
*/
|
|
7125
|
+
observeOriginOccupancy(occupancy) {
|
|
7126
|
+
const daemonLine = occupancy.kind === "daemon";
|
|
7127
|
+
const what = daemonLine ? "This app was not given the key to that server, so it did not start a second one" : `${this.backendUrl} answered without proving it is a Local Operator daemon, so this app did not start one there`;
|
|
7128
|
+
const detail = `${what}. It keeps probing for a server it can open.`;
|
|
7129
|
+
this.daemonState.observe(
|
|
7130
|
+
occupancy.kind === "silent" ? { kind: "unanswered", cause: occupancy.cause, detail } : { kind: "unattachable", detail }
|
|
7131
|
+
);
|
|
7132
|
+
logger.info(`Not spawning a daemon: ${detail}`, LogFileType.BACKEND);
|
|
7133
|
+
this.notifyStatus();
|
|
7134
|
+
}
|
|
7135
|
+
/**
|
|
7136
|
+
* The token governing the daemon this app spawned in a PREVIOUS run, read from
|
|
7137
|
+
* the 0600 file beside the app's other per-user state, or null.
|
|
7138
|
+
*
|
|
7139
|
+
* Read-only on purpose: a launch that has not spawned anything yet must not
|
|
7140
|
+
* create a credential, or "this app holds no token" (a first run, and the
|
|
7141
|
+
* honest reason to decline a daemon it cannot open) would become unreachable.
|
|
7142
|
+
*/
|
|
7143
|
+
persistedDesktopToken() {
|
|
7144
|
+
try {
|
|
7145
|
+
const stored = fs.readFileSync(this.desktopTokenFile(), "utf8").trim();
|
|
7146
|
+
return stored || null;
|
|
7147
|
+
} catch {
|
|
7148
|
+
return null;
|
|
7149
|
+
}
|
|
7150
|
+
}
|
|
7151
|
+
/**
|
|
7152
|
+
* Mint the token for a daemon this app is about to spawn, and persist it so the
|
|
7153
|
+
* next launch can re-attach to that daemon instead of starting a second one.
|
|
7154
|
+
*
|
|
7155
|
+
* A token that cannot be written still governs this run's child - failing to
|
|
7156
|
+
* start over a cache file would be the worse trade - and the log line names
|
|
7157
|
+
* the cost, which is the stranded daemon next launch.
|
|
7158
|
+
*/
|
|
7159
|
+
mintDesktopToken() {
|
|
7160
|
+
const token = node_crypto.randomBytes(32).toString("hex");
|
|
7161
|
+
try {
|
|
7162
|
+
const file = this.desktopTokenFile();
|
|
7163
|
+
fs.mkdirSync(path$1.dirname(file), { recursive: true, mode: 448 });
|
|
7164
|
+
fs.writeFileSync(file, token, { mode: 384 });
|
|
7165
|
+
} catch (error) {
|
|
7166
|
+
logger.warn(
|
|
7167
|
+
"Could not persist the desktop token; a daemon spawned now will not be re-attachable after a restart.",
|
|
7168
|
+
LogFileType.BACKEND,
|
|
7169
|
+
error
|
|
7170
|
+
);
|
|
7171
|
+
}
|
|
7172
|
+
return token;
|
|
7173
|
+
}
|
|
7174
|
+
desktopTokenPath = null;
|
|
7175
|
+
desktopTokenFile() {
|
|
7176
|
+
if (this.desktopTokenPath === null) {
|
|
7177
|
+
this.desktopTokenPath = path$1.join(this.appDataPath, DESKTOP_TOKEN_FILENAME);
|
|
7178
|
+
}
|
|
7179
|
+
return this.desktopTokenPath;
|
|
7180
|
+
}
|
|
6708
7181
|
/**
|
|
6709
7182
|
* One probe of the daemon this app is attached to.
|
|
6710
7183
|
*
|
|
@@ -6722,9 +7195,12 @@ class BackendServiceManager {
|
|
|
6722
7195
|
const expected = this.daemonState.expectedInstanceId();
|
|
6723
7196
|
if (!expected) {
|
|
6724
7197
|
const ok = await this.checkHealth();
|
|
6725
|
-
|
|
6726
|
-
|
|
6727
|
-
|
|
7198
|
+
if (ok) return { kind: "identified" };
|
|
7199
|
+
const failure = this.lastHealthFailure;
|
|
7200
|
+
return {
|
|
7201
|
+
kind: failure === null || failure === "refused" ? "failed" : "unanswered",
|
|
7202
|
+
cause: failure ?? "other",
|
|
7203
|
+
detail: failure === null ? `${this.backendUrl}${HEALTH_PATH$1} answered a status this app does not accept` : `No answer from ${this.backendUrl}${HEALTH_PATH$1}`
|
|
6728
7204
|
};
|
|
6729
7205
|
}
|
|
6730
7206
|
const probe = await probeIdentity(this.backendUrl, expected, {
|
|
@@ -6747,8 +7223,12 @@ class BackendServiceManager {
|
|
|
6747
7223
|
detail: `${this.backendUrl} answered but is not a Local Operator daemon (${probe.detail})`
|
|
6748
7224
|
};
|
|
6749
7225
|
case "unreachable":
|
|
6750
|
-
return {
|
|
7226
|
+
return probe.cause === "refused" ? {
|
|
6751
7227
|
kind: "failed",
|
|
7228
|
+
detail: `${this.backendUrl} refused the connection (${probe.detail})`
|
|
7229
|
+
} : {
|
|
7230
|
+
kind: "unanswered",
|
|
7231
|
+
cause: probe.cause,
|
|
6752
7232
|
detail: `${this.backendUrl} did not answer (${probe.detail})`
|
|
6753
7233
|
};
|
|
6754
7234
|
}
|
|
@@ -10720,11 +11200,6 @@ class BrowserHost {
|
|
|
10720
11200
|
return AWAIT_SLICE_MS;
|
|
10721
11201
|
}
|
|
10722
11202
|
// ---- operations the app's own chrome uses (design 11.2, 6.1) -------------
|
|
10723
|
-
/**
|
|
10724
|
-
* The projection the renderer renders: the tab strip, the URL bar and the
|
|
10725
|
-
* per-tab ownership markers. It never carries a nonce — the renderer is not an
|
|
10726
|
-
* agent and has no reason to hold a capability (design 11.7).
|
|
10727
|
-
*/
|
|
10728
11203
|
/**
|
|
10729
11204
|
* Record a main-frame load failure for a tab.
|
|
10730
11205
|
*
|
|
@@ -10778,6 +11253,32 @@ class BrowserHost {
|
|
|
10778
11253
|
if (!record || record.view.webContents.isDestroyed()) return false;
|
|
10779
11254
|
return record.view.webContents.isLoading();
|
|
10780
11255
|
}
|
|
11256
|
+
/**
|
|
11257
|
+
* The projection the renderer renders: the tab strip, the URL bar and the
|
|
11258
|
+
* per-tab ownership markers. It never carries a nonce — the renderer is not an
|
|
11259
|
+
* agent and has no reason to hold a capability (design 11.7).
|
|
11260
|
+
*
|
|
11261
|
+
* `sessionId` IS projected, and the nonce is not, and the difference is the
|
|
11262
|
+
* whole of the rule rather than an inconsistency to iron out. A conversation's
|
|
11263
|
+
* pane has to answer "which of these tabs belong to THIS conversation"
|
|
11264
|
+
* (docs/design/browser-approval-ux.md 7.2), and the registry already answers it
|
|
11265
|
+
* with one field: an agent tab carries the session that created it
|
|
11266
|
+
* (`registry.ts:231`) and a handed-over user tab carries the session it was
|
|
11267
|
+
* handed to (`:371`), so one field answers both questions. That field is a
|
|
11268
|
+
* NAME — the same id the app already puts in its own chat routes, and the same
|
|
11269
|
+
* value `handOver` matches against — while the NONCE is the capability that
|
|
11270
|
+
* lets its holder DRIVE the tab. Projecting the name tells the user which
|
|
11271
|
+
* conversation a tab belongs to; projecting the nonce would hand the renderer
|
|
11272
|
+
* (and anything that can read the renderer's IPC) the ability to act as an
|
|
11273
|
+
* agent, which is exactly what `mayDrive` is the gate for. So the name
|
|
11274
|
+
* travels and the capability does not — `registry.snapshot()` carries it for the
|
|
11275
|
+
* same reason, and THIS projection is the one the UI reads.
|
|
11276
|
+
*
|
|
11277
|
+
* It sits on this member rather than where it was written: it was left as a
|
|
11278
|
+
* detached block after this class's section rule, with `recordLoadFailure`'s own
|
|
11279
|
+
* doc directly beneath it, so the most load-bearing prose in the main-process
|
|
11280
|
+
* change documented nothing (review round 1, NIT B).
|
|
11281
|
+
*/
|
|
10781
11282
|
chromeState() {
|
|
10782
11283
|
const activeRecord = this.registry.activeTab;
|
|
10783
11284
|
const active = activeRecord && !activeRecord.view.webContents.isDestroyed() ? activeRecord : null;
|
|
@@ -10788,6 +11289,13 @@ class BrowserHost {
|
|
|
10788
11289
|
title: this.titleForChrome(entry.title) || "New tab",
|
|
10789
11290
|
url: entry.url,
|
|
10790
11291
|
owner: entry.owner,
|
|
11292
|
+
// WHICH CONVERSATION THIS TAB BELONGS TO, and the ONLY new field this
|
|
11293
|
+
// change adds to the wire. `null` is a real and common value — a restored
|
|
11294
|
+
// tab is nobody's (`registry.ts:231`), a user tab that was never handed
|
|
11295
|
+
// over is nobody's, and a tab handed back goes back to `null` (`:386`) —
|
|
11296
|
+
// and it means "not any conversation's", which is why a conversation's
|
|
11297
|
+
// scope shows it under no scope but `"all"` (spec 7.2).
|
|
11298
|
+
sessionId: entry.sessionId,
|
|
10791
11299
|
active: entry.active,
|
|
10792
11300
|
restored: entry.restored,
|
|
10793
11301
|
handedOver: entry.handedTo !== null,
|
|
@@ -16752,10 +17260,10 @@ ${heal.removed.join("\n")}`,
|
|
|
16752
17260
|
LogFileType.UPDATE_SERVICE
|
|
16753
17261
|
);
|
|
16754
17262
|
if (!silent) {
|
|
16755
|
-
this.sendToRenderer(
|
|
16756
|
-
"backend
|
|
16757
|
-
|
|
16758
|
-
);
|
|
17263
|
+
this.sendToRenderer("backend-update-error", {
|
|
17264
|
+
message: "Unable to determine backend version.",
|
|
17265
|
+
phase: "check"
|
|
17266
|
+
});
|
|
16759
17267
|
}
|
|
16760
17268
|
return { status: "unavailable", info: null };
|
|
16761
17269
|
}
|
|
@@ -16765,10 +17273,10 @@ ${heal.removed.join("\n")}`,
|
|
|
16765
17273
|
LogFileType.UPDATE_SERVICE
|
|
16766
17274
|
);
|
|
16767
17275
|
if (!silent) {
|
|
16768
|
-
this.sendToRenderer(
|
|
16769
|
-
"
|
|
16770
|
-
|
|
16771
|
-
);
|
|
17276
|
+
this.sendToRenderer("backend-update-error", {
|
|
17277
|
+
message: "The installed server version could not be determined, so no update was offered. Restart the app to try again.",
|
|
17278
|
+
phase: "check"
|
|
17279
|
+
});
|
|
16772
17280
|
}
|
|
16773
17281
|
return { status: "unavailable", info: null };
|
|
16774
17282
|
}
|
|
@@ -16784,10 +17292,10 @@ ${heal.removed.join("\n")}`,
|
|
|
16784
17292
|
LogFileType.UPDATE_SERVICE
|
|
16785
17293
|
);
|
|
16786
17294
|
if (!silent) {
|
|
16787
|
-
this.sendToRenderer(
|
|
16788
|
-
"backend
|
|
16789
|
-
|
|
16790
|
-
);
|
|
17295
|
+
this.sendToRenderer("backend-update-error", {
|
|
17296
|
+
message: "Unable to determine backend version.",
|
|
17297
|
+
phase: "check"
|
|
17298
|
+
});
|
|
16791
17299
|
}
|
|
16792
17300
|
return { status: "unavailable", info: null };
|
|
16793
17301
|
}
|
|
@@ -16843,10 +17351,10 @@ ${heal.removed.join("\n")}`,
|
|
|
16843
17351
|
error
|
|
16844
17352
|
);
|
|
16845
17353
|
if (!silent && this.mainWindow && !this.mainWindow.isDestroyed() && this.mainWindow.webContents && !this.mainWindow.webContents.isDestroyed()) {
|
|
16846
|
-
this.mainWindow.webContents.send(
|
|
16847
|
-
|
|
16848
|
-
|
|
16849
|
-
);
|
|
17354
|
+
this.mainWindow.webContents.send("backend-update-error", {
|
|
17355
|
+
message: error.message,
|
|
17356
|
+
phase: "check"
|
|
17357
|
+
});
|
|
16850
17358
|
}
|
|
16851
17359
|
return { status: "unavailable", info: null };
|
|
16852
17360
|
}
|
|
@@ -16933,10 +17441,10 @@ ${heal.removed.join("\n")}`,
|
|
|
16933
17441
|
LogFileType.UPDATE_SERVICE
|
|
16934
17442
|
);
|
|
16935
17443
|
if (this.mainWindow && !this.mainWindow.isDestroyed() && this.mainWindow.webContents && !this.mainWindow.webContents.isDestroyed()) {
|
|
16936
|
-
this.mainWindow.webContents.send(
|
|
16937
|
-
"backend
|
|
16938
|
-
"
|
|
16939
|
-
);
|
|
17444
|
+
this.mainWindow.webContents.send("backend-update-error", {
|
|
17445
|
+
message: "No backend service reference available, cannot update.",
|
|
17446
|
+
phase: "update"
|
|
17447
|
+
});
|
|
16940
17448
|
}
|
|
16941
17449
|
return false;
|
|
16942
17450
|
}
|
|
@@ -16948,10 +17456,10 @@ ${heal.removed.join("\n")}`,
|
|
|
16948
17456
|
LogFileType.UPDATE_SERVICE
|
|
16949
17457
|
);
|
|
16950
17458
|
if (this.mainWindow && !this.mainWindow.isDestroyed() && this.mainWindow.webContents && !this.mainWindow.webContents.isDestroyed()) {
|
|
16951
|
-
this.mainWindow.webContents.send(
|
|
16952
|
-
"
|
|
16953
|
-
"
|
|
16954
|
-
);
|
|
17459
|
+
this.mainWindow.webContents.send("backend-update-error", {
|
|
17460
|
+
message: "No python server is available, nothing to update.",
|
|
17461
|
+
phase: "update"
|
|
17462
|
+
});
|
|
16955
17463
|
}
|
|
16956
17464
|
return false;
|
|
16957
17465
|
case LocalOperatorStartupMode.EXISTING_SERVER:
|
|
@@ -16984,6 +17492,10 @@ ${heal.removed.join("\n")}`,
|
|
|
16984
17492
|
`Unknown startup mode: ${startupMode}`,
|
|
16985
17493
|
LogFileType.UPDATE_SERVICE
|
|
16986
17494
|
);
|
|
17495
|
+
this.sendToRenderer("backend-update-error", {
|
|
17496
|
+
message: "The app could not tell how this server was started, so it did not update it. See the update service log.",
|
|
17497
|
+
phase: "update"
|
|
17498
|
+
});
|
|
16987
17499
|
return false;
|
|
16988
17500
|
}
|
|
16989
17501
|
if (!this.backendService.isUsingExternalBackend()) {
|
|
@@ -17014,13 +17526,13 @@ ${heal.removed.join("\n")}`,
|
|
|
17014
17526
|
LogFileType.UPDATE_SERVICE
|
|
17015
17527
|
);
|
|
17016
17528
|
await this.restartBackendAfterFailedUpgrade();
|
|
17017
|
-
this.sendToRenderer(
|
|
17018
|
-
"
|
|
17019
|
-
"
|
|
17020
|
-
);
|
|
17529
|
+
this.sendToRenderer("backend-update-error", {
|
|
17530
|
+
message: "The bundled server's Python environment could not be found, so the update did not run. Please reinstall the application.",
|
|
17531
|
+
phase: "update"
|
|
17532
|
+
});
|
|
17021
17533
|
return false;
|
|
17022
17534
|
}
|
|
17023
|
-
const pip = buildPipUpgradeCommand(pythonPath2);
|
|
17535
|
+
const pip = buildPipUpgradeCommand(pythonPath2, targetVersion ?? null);
|
|
17024
17536
|
const versionBefore = await this.readBundledBackendVersion(pythonPath2);
|
|
17025
17537
|
logger.info(
|
|
17026
17538
|
`Executing pip command: ${pip.display}`,
|
|
@@ -17054,10 +17566,10 @@ ${pipRun.stderr.trim()}`,
|
|
|
17054
17566
|
LogFileType.UPDATE_SERVICE
|
|
17055
17567
|
);
|
|
17056
17568
|
const serverIsBack = await this.restartBackendAfterFailedUpgrade();
|
|
17057
|
-
this.sendToRenderer(
|
|
17058
|
-
"
|
|
17059
|
-
|
|
17060
|
-
);
|
|
17569
|
+
this.sendToRenderer("backend-update-error", {
|
|
17570
|
+
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.`,
|
|
17571
|
+
phase: "update"
|
|
17572
|
+
});
|
|
17061
17573
|
return false;
|
|
17062
17574
|
}
|
|
17063
17575
|
logger.info(
|
|
@@ -17093,10 +17605,10 @@ ${pipRun.stderr.trim()}`,
|
|
|
17093
17605
|
LogFileType.UPDATE_SERVICE
|
|
17094
17606
|
);
|
|
17095
17607
|
if (this.mainWindow) {
|
|
17096
|
-
this.mainWindow.webContents.send(
|
|
17097
|
-
"
|
|
17098
|
-
|
|
17099
|
-
);
|
|
17608
|
+
this.mainWindow.webContents.send("backend-update-error", {
|
|
17609
|
+
message: "Backend was updated but failed to restart properly. Please restart the application.",
|
|
17610
|
+
phase: "update"
|
|
17611
|
+
});
|
|
17100
17612
|
}
|
|
17101
17613
|
return false;
|
|
17102
17614
|
}
|
|
@@ -17108,10 +17620,10 @@ ${pipRun.stderr.trim()}`,
|
|
|
17108
17620
|
);
|
|
17109
17621
|
this.backendService.setAutoUpdating(false);
|
|
17110
17622
|
if (this.mainWindow) {
|
|
17111
|
-
this.mainWindow.webContents.send(
|
|
17112
|
-
"
|
|
17113
|
-
|
|
17114
|
-
);
|
|
17623
|
+
this.mainWindow.webContents.send("backend-update-error", {
|
|
17624
|
+
message: "Backend was updated but failed to restart. Please restart the application.",
|
|
17625
|
+
phase: "update"
|
|
17626
|
+
});
|
|
17115
17627
|
}
|
|
17116
17628
|
return false;
|
|
17117
17629
|
}
|
|
@@ -17123,10 +17635,10 @@ ${pipRun.stderr.trim()}`,
|
|
|
17123
17635
|
`Backend did not report version ${targetVersion ?? "the new one"} after the update`,
|
|
17124
17636
|
LogFileType.UPDATE_SERVICE
|
|
17125
17637
|
);
|
|
17126
|
-
this.sendToRenderer(
|
|
17127
|
-
"
|
|
17128
|
-
|
|
17129
|
-
);
|
|
17638
|
+
this.sendToRenderer("backend-update-error", {
|
|
17639
|
+
message: `The server restarted but did not report ${targetVersion ? `version ${targetVersion}` : "the updated version"}. Check the update service log, then try again.`,
|
|
17640
|
+
phase: "update"
|
|
17641
|
+
});
|
|
17130
17642
|
return false;
|
|
17131
17643
|
}
|
|
17132
17644
|
logger.info(
|
|
@@ -17163,10 +17675,10 @@ ${pipRun.stderr.trim()}`,
|
|
|
17163
17675
|
}
|
|
17164
17676
|
}
|
|
17165
17677
|
if (this.mainWindow && !this.mainWindow.isDestroyed() && this.mainWindow.webContents && !this.mainWindow.webContents.isDestroyed()) {
|
|
17166
|
-
this.mainWindow.webContents.send(
|
|
17167
|
-
|
|
17168
|
-
|
|
17169
|
-
);
|
|
17678
|
+
this.mainWindow.webContents.send("backend-update-error", {
|
|
17679
|
+
message: error.message,
|
|
17680
|
+
phase: "update"
|
|
17681
|
+
});
|
|
17170
17682
|
}
|
|
17171
17683
|
return false;
|
|
17172
17684
|
}
|