local-operator-ui 0.25.10 → 0.25.11
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 +531 -57
- package/out/renderer/assets/{_basePickBy-Dc7CsGGc.js → _basePickBy--Yue-XLQ.js} +1 -1
- package/out/renderer/assets/{_baseUniq-BcI4o9r1.js → _baseUniq-CX84XXMp.js} +1 -1
- package/out/renderer/assets/{agent-details-page-Bbzz81h0.js → agent-details-page-BjoWEolx.js} +1 -1
- package/out/renderer/assets/{agent-hub-page-BehmTi1m.js → agent-hub-page-BuMeC5YA.js} +1 -1
- package/out/renderer/assets/{agents-page-C-nkjaiF.js → agents-page-DMGAWpXp.js} +2 -2
- package/out/renderer/assets/{arc-DRqvxu6i.js → arc-CR3HNaPp.js} +1 -1
- package/out/renderer/assets/{architectureDiagram-IEHRJDOE-C-aO5oDt.js → architectureDiagram-IEHRJDOE-DLZPKw96.js} +1 -1
- package/out/renderer/assets/{arrow-left-CiJceI8e.js → arrow-left-CIBQH5Fe.js} +1 -1
- package/out/renderer/assets/{blockDiagram-JOT3LUYC-BS3JLZvl.js → blockDiagram-JOT3LUYC-BGnNcmsO.js} +1 -1
- package/out/renderer/assets/{browser-page-BNjbdOsx.js → browser-page-B02RKtfN.js} +1 -1
- package/out/renderer/assets/{c4Diagram-VJAJSXHY-Beuab6Az.js → c4Diagram-VJAJSXHY-Bu96GUil.js} +1 -1
- package/out/renderer/assets/channel-Dekw979G.js +1 -0
- package/out/renderer/assets/{chunk-4BMEZGHF-nHScUB-6.js → chunk-4BMEZGHF-Bc2IBqRl.js} +1 -1
- package/out/renderer/assets/{chunk-A2AXSNBT-CpH0MbZ1.js → chunk-A2AXSNBT-C7PMbmgd.js} +1 -1
- package/out/renderer/assets/{chunk-AEK57VVT-B8z14xij.js → chunk-AEK57VVT-Dy6-mgEo.js} +1 -1
- package/out/renderer/assets/{chunk-D6G4REZN-X_olriGu.js → chunk-D6G4REZN-cBOMihDM.js} +1 -1
- package/out/renderer/assets/{chunk-RZ5BOZE2-k05aTsX1.js → chunk-RZ5BOZE2-DtZPVT7y.js} +1 -1
- package/out/renderer/assets/{chunk-XZIHB7SX-CFzOVhMD.js → chunk-XZIHB7SX-BxlbgWo7.js} +1 -1
- package/out/renderer/assets/classDiagram-GIVACNV2-Bl3vsW8S.js +1 -0
- package/out/renderer/assets/classDiagram-v2-COTLJTTW-Bl3vsW8S.js +1 -0
- package/out/renderer/assets/clone-B0Lw9tfa.js +1 -0
- package/out/renderer/assets/{compact-pagination-DcQd1Yw-.js → compact-pagination-F8MDF-Kl.js} +1 -1
- package/out/renderer/assets/{dagre-OKDRZEBW-CzfJf6s3.js → dagre-OKDRZEBW-NnFztN4b.js} +1 -1
- package/out/renderer/assets/{diagram-SSKATNLV-C52lZrA0.js → diagram-SSKATNLV-2BWtLWap.js} +1 -1
- package/out/renderer/assets/{diagram-VNBRO52H-PR22VW_W.js → diagram-VNBRO52H-CENgJuWn.js} +1 -1
- package/out/renderer/assets/{erDiagram-Q7BY3M3F-C_GPAogs.js → erDiagram-Q7BY3M3F-CCc0_w5h.js} +1 -1
- package/out/renderer/assets/{flowDiagram-4HSFHLVR-DZnJIoPr.js → flowDiagram-4HSFHLVR-DBpVSzRM.js} +1 -1
- package/out/renderer/assets/{ganttDiagram-APWFNJXF-DQBsrrpK.js → ganttDiagram-APWFNJXF-mf6DXttE.js} +1 -1
- package/out/renderer/assets/{gitGraphDiagram-7IBYFJ6S-AfFACac2.js → gitGraphDiagram-7IBYFJ6S-Bq8O1rxL.js} +1 -1
- package/out/renderer/assets/{graph-Bjxe6B9O.js → graph-qxmVeyvg.js} +1 -1
- package/out/renderer/assets/icon-BF__WH76.css +1 -0
- package/out/renderer/assets/{index-C8lN9NIR.js → index-4byN7Q2L.js} +1 -1
- package/out/renderer/assets/{index-BHDf26BA.js → index-CT_UJzBT.js} +1 -1
- package/out/renderer/assets/{index-DOR1dMWN.js → index-DLktHc2h.js} +340 -340
- package/out/renderer/assets/{infoDiagram-PH2N3AL5-g5maBrjp.js → infoDiagram-PH2N3AL5-BCOEX5M1.js} +1 -1
- package/out/renderer/assets/{installer-BQl_2uJr.js → installer-BsQIORMk.js} +1 -1
- package/out/renderer/assets/{journeyDiagram-U35MCT3I-BlXoyGIt.js → journeyDiagram-U35MCT3I-BeoAemLm.js} +1 -1
- package/out/renderer/assets/{kanban-definition-NDS4AKOZ-BSPXz__X.js → kanban-definition-NDS4AKOZ-g2cpPHPh.js} +1 -1
- package/out/renderer/assets/{layout-R71oDIMJ.js → layout-BtO_FG0y.js} +1 -1
- package/out/renderer/assets/{legacy-agents-page-Dl_OPtYj.js → legacy-agents-page-CIz8TVKP.js} +1 -1
- package/out/renderer/assets/{mermaid.core-CjJqe_d-.js → mermaid.core-CdVpQ79S.js} +5 -5
- package/out/renderer/assets/{mindmap-definition-ALO5MXBD-BujOxX7c.js → mindmap-definition-ALO5MXBD-CHVbB4ku.js} +1 -1
- package/out/renderer/assets/{page-header-B_TbLiZC.js → page-header-BGvY3vKz.js} +1 -1
- package/out/renderer/assets/{parseISO-WLamiqnN.js → parseISO-BejL_jy7.js} +1 -1
- package/out/renderer/assets/{pieDiagram-IB7DONF6-CHZYRhVG.js → pieDiagram-IB7DONF6-BBOrJKFD.js} +1 -1
- package/out/renderer/assets/{quadrantDiagram-7GDLP6J5-mOsQ00cE.js → quadrantDiagram-7GDLP6J5-DSBlTbPc.js} +1 -1
- package/out/renderer/assets/{radar-MK3ICKWK-C0G40Mrt.js → radar-MK3ICKWK-pZgkhoLd.js} +1 -1
- package/out/renderer/assets/{radient-auth-buttons-BvMD0K4b.js → radient-auth-buttons-BnXJwZfD.js} +1 -1
- package/out/renderer/assets/{requirementDiagram-KVF5MWMF-BYjlnn4c.js → requirementDiagram-KVF5MWMF-BUV7_UZP.js} +1 -1
- package/out/renderer/assets/{sankeyDiagram-QLVOVGJD-Bd-AbvK-.js → sankeyDiagram-QLVOVGJD-DfvIpv82.js} +1 -1
- package/out/renderer/assets/{schedules-page-DieuF5Xp.js → schedules-page-D0aF6oGQ.js} +1 -1
- package/out/renderer/assets/{sequenceDiagram-X6HHIX6F-BcmQ0lXI.js → sequenceDiagram-X6HHIX6F-DFuvLBd0.js} +1 -1
- package/out/renderer/assets/{settings-page-DIb8gPLb.js → settings-page-DrjsU9En.js} +1 -1
- package/out/renderer/assets/{shield-check-BJ9wbi-7.js → shield-check-DusR2dvL.js} +1 -1
- package/out/renderer/assets/{stateDiagram-DGXRK772-DDC41wRc.js → stateDiagram-DGXRK772-CmDy9j2_.js} +1 -1
- package/out/renderer/assets/stateDiagram-v2-YXO3MK2T-Bv3iQPCj.js +1 -0
- package/out/renderer/assets/{timeline-definition-BDJGKUSR-BvNMSpcF.js → timeline-definition-BDJGKUSR-De6scz66.js} +1 -1
- package/out/renderer/assets/{use-agent-like-mutation-AG1SEqKe.js → use-agent-like-mutation-CxJR8287.js} +1 -1
- package/out/renderer/assets/{xychartDiagram-VJFVF3MP-CrFd9Hqj.js → xychartDiagram-VJFVF3MP-Cz0S_Epm.js} +1 -1
- package/out/renderer/index.html +3 -3
- package/out/renderer/installer.html +4 -4
- package/package.json +2 -2
- 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/stateDiagram-v2-YXO3MK2T-tZkQuuhw.js +0 -1
- /package/out/renderer/assets/{icon-giwXJbjk.js → icon-CEd0ml4Y.js} +0 -0
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);
|
|
@@ -3622,6 +3702,8 @@ const apiConfig = {
|
|
|
3622
3702
|
baseUrl: backendConfig.VITE_LOCAL_OPERATOR_API_URL
|
|
3623
3703
|
};
|
|
3624
3704
|
const DEGRADED_AFTER_FAILURES = 3;
|
|
3705
|
+
const UNANSWERED_BEFORE_DETACHED = 9;
|
|
3706
|
+
const TRANSPORT_EVIDENCE_MS = 3e4;
|
|
3625
3707
|
const PROBE_INTERVAL_MS = 1e4;
|
|
3626
3708
|
const PROBE_TIMEOUT_MS$1 = 2e3;
|
|
3627
3709
|
const DETACHED_AFTER_MS = 9e4;
|
|
@@ -3642,11 +3724,16 @@ class DaemonStateMachine {
|
|
|
3642
3724
|
updatedAt;
|
|
3643
3725
|
detachedSince = null;
|
|
3644
3726
|
backoffMs = REATTACH_BACKOFF_MS;
|
|
3727
|
+
/** Consecutive probes that ran out of budget with no answer at all. */
|
|
3728
|
+
unanswered = 0;
|
|
3729
|
+
/** When a desktop request was last answered, or null. */
|
|
3730
|
+
lastTransportAt = null;
|
|
3645
3731
|
/** Adopt a validated daemon (`owned` says whether this app spawned it). */
|
|
3646
3732
|
attach(identity, options) {
|
|
3647
3733
|
this.identity = identity;
|
|
3648
3734
|
this.owned = options.owned;
|
|
3649
3735
|
this.failures = 0;
|
|
3736
|
+
this.unanswered = 0;
|
|
3650
3737
|
this.detachedSince = null;
|
|
3651
3738
|
this.backoffMs = REATTACH_BACKOFF_MS;
|
|
3652
3739
|
this.state = "attached";
|
|
@@ -3676,6 +3763,7 @@ class DaemonStateMachine {
|
|
|
3676
3763
|
switch (observation.kind) {
|
|
3677
3764
|
case "identified":
|
|
3678
3765
|
this.failures = 0;
|
|
3766
|
+
this.unanswered = 0;
|
|
3679
3767
|
this.capabilityStatus = null;
|
|
3680
3768
|
this.detachedSince = null;
|
|
3681
3769
|
this.backoffMs = REATTACH_BACKOFF_MS;
|
|
@@ -3701,11 +3789,20 @@ class DaemonStateMachine {
|
|
|
3701
3789
|
this.enterDetached(observation.detail);
|
|
3702
3790
|
} else {
|
|
3703
3791
|
this.state = "degraded";
|
|
3704
|
-
this.detail = `${observation.detail} (probe ${this.failures} of ${DEGRADED_AFTER_FAILURES})`;
|
|
3792
|
+
this.detail = `${observation.detail} (probe ${this.failures} of ${DEGRADED_AFTER_FAILURES}).${this.transportEvidenceSuffix("so it is serving")}`;
|
|
3793
|
+
}
|
|
3794
|
+
break;
|
|
3795
|
+
case "unanswered":
|
|
3796
|
+
this.observeUnanswered(observation.detail);
|
|
3797
|
+
break;
|
|
3798
|
+
case "unattachable":
|
|
3799
|
+
if (this.state !== "attached" && this.state !== "replaced") {
|
|
3800
|
+
this.state = "wedged";
|
|
3705
3801
|
}
|
|
3802
|
+
this.detail = observation.detail;
|
|
3706
3803
|
break;
|
|
3707
3804
|
case "pid-dead":
|
|
3708
|
-
this.enterDetached("The daemon's process is gone.");
|
|
3805
|
+
this.enterDetached("The daemon's process is gone.", true);
|
|
3709
3806
|
break;
|
|
3710
3807
|
case "no-candidate":
|
|
3711
3808
|
this.enterDetached(observation.detail);
|
|
@@ -3714,7 +3811,96 @@ class DaemonStateMachine {
|
|
|
3714
3811
|
this.updatedAt = this.now();
|
|
3715
3812
|
return this.state;
|
|
3716
3813
|
}
|
|
3717
|
-
|
|
3814
|
+
/**
|
|
3815
|
+
* Fold a probe that ran out of budget with no answer.
|
|
3816
|
+
*
|
|
3817
|
+
* Three rules, in order, and each one exists because the alternative was
|
|
3818
|
+
* measured on the operator's machine:
|
|
3819
|
+
*
|
|
3820
|
+
* 1. A successful desktop request inside the evidence window means the
|
|
3821
|
+
* connection is ALIVE and the probe is the thing that is wrong. The state
|
|
3822
|
+
* does not move at all - not to `degraded`, because the app is talking to
|
|
3823
|
+
* its daemon right now - and the detail records both facts. This is the
|
|
3824
|
+
* rule that makes "no surface may say offline while the app is still
|
|
3825
|
+
* reading from it" true by construction rather than by each surface
|
|
3826
|
+
* remembering to check a second flag.
|
|
3827
|
+
* 2. Without that evidence, misses accumulate as `degraded` (usable), which
|
|
3828
|
+
* is all a 2 s budget on a busy box justifies.
|
|
3829
|
+
* 3. Only `UNANSWERED_BEFORE_DETACHED` consecutive misses detach, and the
|
|
3830
|
+
* detail says how many were counted, so the log line names the evidence.
|
|
3831
|
+
*/
|
|
3832
|
+
observeUnanswered(detail) {
|
|
3833
|
+
this.unanswered += 1;
|
|
3834
|
+
if (this.transportEvidenceFresh()) {
|
|
3835
|
+
this.detail = `${detail}${this.transportEvidenceSuffix("so it is serving and the probe's budget is what expired")} (no answer to probe ${this.unanswered}).`;
|
|
3836
|
+
return;
|
|
3837
|
+
}
|
|
3838
|
+
if (this.unanswered >= UNANSWERED_BEFORE_DETACHED) {
|
|
3839
|
+
this.enterDetached(detail);
|
|
3840
|
+
return;
|
|
3841
|
+
}
|
|
3842
|
+
this.state = "degraded";
|
|
3843
|
+
this.detail = `${detail} (no answer to probe ${this.unanswered} of ${UNANSWERED_BEFORE_DETACHED})`;
|
|
3844
|
+
}
|
|
3845
|
+
/**
|
|
3846
|
+
* Record that a real request against the attached daemon was answered.
|
|
3847
|
+
*
|
|
3848
|
+
* WHY this is the state machine's business and not a caller's: the probe and
|
|
3849
|
+
* the transport disagree regularly on a box running long turns - the probe
|
|
3850
|
+
* has a 2 s budget, a session list has a 30 s one - and the one carrying the
|
|
3851
|
+
* user's data is the transport. Recording it here is what lets
|
|
3852
|
+
* {@link observeUnanswered} prefer it without every surface re-deriving the
|
|
3853
|
+
* same fact.
|
|
3854
|
+
*
|
|
3855
|
+
* It revives `degraded` (the daemon just answered, so the missed probes are
|
|
3856
|
+
* explained) and never a `detached` connection: coming back from those is
|
|
3857
|
+
* `recoverFromDetachment`'s job, because that path also has to re-prove the
|
|
3858
|
+
* daemon's IDENTITY before anything may use it.
|
|
3859
|
+
*
|
|
3860
|
+
* @returns whether the state actually moved, so the caller only pushes a
|
|
3861
|
+
* snapshot when there is something new to push.
|
|
3862
|
+
*/
|
|
3863
|
+
recordTransportSuccess() {
|
|
3864
|
+
const moved = this.state === "degraded";
|
|
3865
|
+
this.lastTransportAt = this.now();
|
|
3866
|
+
this.unanswered = 0;
|
|
3867
|
+
if (moved) {
|
|
3868
|
+
this.failures = 0;
|
|
3869
|
+
this.state = this.identity ? "attached" : "connecting";
|
|
3870
|
+
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.";
|
|
3871
|
+
}
|
|
3872
|
+
this.updatedAt = this.now();
|
|
3873
|
+
return moved;
|
|
3874
|
+
}
|
|
3875
|
+
/** Whether a successful request is recent enough to outrank a probe. */
|
|
3876
|
+
transportEvidenceFresh() {
|
|
3877
|
+
return this.lastTransportAt !== null && this.now() - this.lastTransportAt <= TRANSPORT_EVIDENCE_MS;
|
|
3878
|
+
}
|
|
3879
|
+
/**
|
|
3880
|
+
* The clause a sentence owes when THIS app's own request is the evidence
|
|
3881
|
+
* behind a connection the app has not detached from, or the empty string when
|
|
3882
|
+
* there is no such evidence.
|
|
3883
|
+
*
|
|
3884
|
+
* Shared rather than copied: three arms carry it (a probe budget that expired,
|
|
3885
|
+
* a refusal past the detach threshold, and a refusal short of it), and a third
|
|
3886
|
+
* copy is how one of them came to say "refused the connection" while the app
|
|
3887
|
+
* was still holding the connection open on the strength of its own answered
|
|
3888
|
+
* request.
|
|
3889
|
+
*/
|
|
3890
|
+
transportEvidenceSuffix(tail) {
|
|
3891
|
+
if (!this.transportEvidenceFresh()) return "";
|
|
3892
|
+
const silentFor = Math.round(
|
|
3893
|
+
(this.now() - this.lastTransportAt) / 1e3
|
|
3894
|
+
);
|
|
3895
|
+
return ` The daemon answered a request ${silentFor}s ago, ${tail}.`;
|
|
3896
|
+
}
|
|
3897
|
+
enterDetached(detail, corroborated = false) {
|
|
3898
|
+
if (!corroborated && this.transportEvidenceFresh()) {
|
|
3899
|
+
this.failures = 0;
|
|
3900
|
+
this.state = "degraded";
|
|
3901
|
+
this.detail = `${detail}${this.transportEvidenceSuffix("so this app is not reporting it as gone")}`;
|
|
3902
|
+
return;
|
|
3903
|
+
}
|
|
3718
3904
|
this.failures = DEGRADED_AFTER_FAILURES;
|
|
3719
3905
|
this.state = "detached";
|
|
3720
3906
|
if (this.detachedSince === null) this.detachedSince = this.now();
|
|
@@ -3770,6 +3956,13 @@ class DaemonStateMachine {
|
|
|
3770
3956
|
desktopAvailable: this.desktopAvailable,
|
|
3771
3957
|
failures: this.failures,
|
|
3772
3958
|
capabilityStatus: this.capabilityStatus,
|
|
3959
|
+
/**
|
|
3960
|
+
* Carried so a surface can explain a `degraded` connection in the same
|
|
3961
|
+
* terms main used, rather than inventing a second reason for it. Both are
|
|
3962
|
+
* also what a reader needs to tell a busy daemon from a gone one.
|
|
3963
|
+
*/
|
|
3964
|
+
unanswered: this.unanswered,
|
|
3965
|
+
lastTransportAt: this.lastTransportAt,
|
|
3773
3966
|
detail: this.detail,
|
|
3774
3967
|
updatedAt: this.updatedAt
|
|
3775
3968
|
};
|
|
@@ -3902,6 +4095,24 @@ function recordAddress(record) {
|
|
|
3902
4095
|
const host = record.host.includes(":") ? `[${record.host}]` : record.host;
|
|
3903
4096
|
return `http://${host}:${record.port}`;
|
|
3904
4097
|
}
|
|
4098
|
+
function classifyUnreachable(error) {
|
|
4099
|
+
const name = error?.name;
|
|
4100
|
+
if (name === "TimeoutError" || name === "AbortError") return "timeout";
|
|
4101
|
+
const code = error?.code ?? error?.cause?.code;
|
|
4102
|
+
switch (code) {
|
|
4103
|
+
case "ECONNREFUSED":
|
|
4104
|
+
case "EHOSTUNREACH":
|
|
4105
|
+
case "ENETUNREACH":
|
|
4106
|
+
case "ENETDOWN":
|
|
4107
|
+
case "EPIPE":
|
|
4108
|
+
return "refused";
|
|
4109
|
+
case "ENOTFOUND":
|
|
4110
|
+
case "EAI_AGAIN":
|
|
4111
|
+
return "unresolved";
|
|
4112
|
+
default:
|
|
4113
|
+
return "other";
|
|
4114
|
+
}
|
|
4115
|
+
}
|
|
3905
4116
|
function readIdentity(payload) {
|
|
3906
4117
|
if (typeof payload !== "object" || payload === null) return null;
|
|
3907
4118
|
const result = payload.result;
|
|
@@ -3958,6 +4169,7 @@ async function probeIdentity(address, expectedInstanceId, options = {}) {
|
|
|
3958
4169
|
} catch (error) {
|
|
3959
4170
|
return {
|
|
3960
4171
|
outcome: "unreachable",
|
|
4172
|
+
cause: classifyUnreachable(error),
|
|
3961
4173
|
detail: error instanceof Error ? error.message : String(error)
|
|
3962
4174
|
};
|
|
3963
4175
|
}
|
|
@@ -4143,14 +4355,16 @@ async function probeUnidentified(address, options = {}) {
|
|
|
4143
4355
|
});
|
|
4144
4356
|
if (response.status !== 200) {
|
|
4145
4357
|
return {
|
|
4146
|
-
reason: "
|
|
4358
|
+
reason: "unready-answer",
|
|
4147
4359
|
detail: `${HEALTH_PATH$1} answered ${response.status} and no record describes this address`
|
|
4148
4360
|
};
|
|
4149
4361
|
}
|
|
4150
4362
|
const payload = await response.json().catch(() => null);
|
|
4151
|
-
|
|
4363
|
+
const identity = readIdentity(payload);
|
|
4364
|
+
if (identity) {
|
|
4152
4365
|
return {
|
|
4153
4366
|
reason: "identity-mismatch",
|
|
4367
|
+
identity,
|
|
4154
4368
|
detail: `a daemon answered but no serve record describes ${address}, so it cannot be proven to be the one this app found`
|
|
4155
4369
|
};
|
|
4156
4370
|
}
|
|
@@ -4161,6 +4375,7 @@ async function probeUnidentified(address, options = {}) {
|
|
|
4161
4375
|
} catch (error) {
|
|
4162
4376
|
return {
|
|
4163
4377
|
reason: "unreachable",
|
|
4378
|
+
cause: classifyUnreachable(error),
|
|
4164
4379
|
detail: error instanceof Error ? error.message : String(error)
|
|
4165
4380
|
};
|
|
4166
4381
|
}
|
|
@@ -5189,9 +5404,15 @@ const SHUTDOWN_TIMEOUT_DEFAULTS = {
|
|
|
5189
5404
|
};
|
|
5190
5405
|
const OWNED_STOP_WORST_MS = SHUTDOWN_TIMEOUT_DEFAULTS.normal + SHUTDOWN_TIMEOUT_DEFAULTS.force;
|
|
5191
5406
|
const READINESS_POLL_INTERVAL_MS = 1e3;
|
|
5407
|
+
const DESKTOP_TOKEN_FILENAME = "desktop-token";
|
|
5192
5408
|
const execPromise = node_util.promisify(node_child_process.exec);
|
|
5193
5409
|
const ENV_VAR_REGEX = /^([^=]+)=(.*)$/;
|
|
5194
5410
|
const LINE_BREAK = /\r?\n/;
|
|
5411
|
+
function classifyDesktopAnswer(status2) {
|
|
5412
|
+
if (status2 >= 200 && status2 < 300) return "accepted";
|
|
5413
|
+
if (status2 === 401 || status2 === 403) return "refused";
|
|
5414
|
+
return "unusable";
|
|
5415
|
+
}
|
|
5195
5416
|
class BackendServiceManager {
|
|
5196
5417
|
process = null;
|
|
5197
5418
|
isRunning = false;
|
|
@@ -5215,6 +5436,19 @@ class BackendServiceManager {
|
|
|
5215
5436
|
remoteConfigured = false;
|
|
5216
5437
|
/** A failed probe or unreadable record is not evidence that spawning is safe. */
|
|
5217
5438
|
discoveryBlocksSpawn = false;
|
|
5439
|
+
/**
|
|
5440
|
+
* The address of a daemon that ANSWERED this app's desktop read without
|
|
5441
|
+
* refusing its credential, and the status it answered, from the last
|
|
5442
|
+
* discovery sweep.
|
|
5443
|
+
*
|
|
5444
|
+
* Non-null means the address is OCCUPIED by a daemon this app may not attach
|
|
5445
|
+
* to *yet*: `start()`'s no-spawn report has to publish that as a running
|
|
5446
|
+
* daemon, because the alternative it would otherwise reach - `no-candidate` -
|
|
5447
|
+
* renders as offline, which is the one sentence this whole change exists to
|
|
5448
|
+
* stop saying about a server that answers (#1170: an unreadable session store
|
|
5449
|
+
* answers 503 instead of an empty 200).
|
|
5450
|
+
*/
|
|
5451
|
+
answeredButUnusable = null;
|
|
5218
5452
|
/**
|
|
5219
5453
|
* Records discovery found alive but unresponsive (pid alive, heartbeat
|
|
5220
5454
|
* stopped). They are why no candidate exists AND why spawning is forbidden,
|
|
@@ -5226,6 +5460,14 @@ class BackendServiceManager {
|
|
|
5226
5460
|
nextRecoveryAt = 0;
|
|
5227
5461
|
appDataPath = electron.app.getPath("userData");
|
|
5228
5462
|
venvPath;
|
|
5463
|
+
/**
|
|
5464
|
+
* Why the last `/health` read did not return 200, for the callers that must
|
|
5465
|
+
* tell a refusal from an expired budget: `null` when it was not a transport
|
|
5466
|
+
* failure at all (the address answered, with a status this path does not
|
|
5467
|
+
* accept).
|
|
5468
|
+
*/
|
|
5469
|
+
lastHealthFailure = null;
|
|
5470
|
+
/** The one probe loop's interval handle. */
|
|
5229
5471
|
healthCheckInterval = null;
|
|
5230
5472
|
/** The only process this manager may terminate. Null means it owns nothing,
|
|
5231
5473
|
* which is a reason to report "nothing to stop" - never to go looking. */
|
|
@@ -5255,6 +5497,29 @@ class BackendServiceManager {
|
|
|
5255
5497
|
shellEnv = {};
|
|
5256
5498
|
// External/dev backends may be explicitly paired through main's environment.
|
|
5257
5499
|
// Managed starts always rotate this; it is never exposed by preload or logs.
|
|
5500
|
+
/**
|
|
5501
|
+
* The credential this app holds for its OWN daemon.
|
|
5502
|
+
*
|
|
5503
|
+
* Read from disk at construction, and REPLACED by a freshly minted, persisted
|
|
5504
|
+
* one on every managed start. Those are two halves of one rule:
|
|
5505
|
+
*
|
|
5506
|
+
* - the read is what makes a daemon the previous run left running
|
|
5507
|
+
* attachable. The app deliberately leaves its daemon serving when it
|
|
5508
|
+
* attached to an external backend, and a daemon it spawned is
|
|
5509
|
+
* env-governed from the spawn (its record publishes `claim_key: ""`), so
|
|
5510
|
+
* the ONLY credential that can ever open it is the token its spawner held.
|
|
5511
|
+
* A launch that starts with no token there declines the daemon it owns and
|
|
5512
|
+
* starts a second one onto its port - measured on the operator's machine as
|
|
5513
|
+
* the 07:47 -> 09:17 sequence, ending in `[Errno 48] Address already in
|
|
5514
|
+
* use`;
|
|
5515
|
+
* - the write is why the token still changes when the app REPLACES a daemon
|
|
5516
|
+
* in place: the relay's cache key is this value (see `getStreamRelay`), and
|
|
5517
|
+
* a token that survived a restart would leave a subscription pointed at a
|
|
5518
|
+
* daemon this app had already stopped.
|
|
5519
|
+
*
|
|
5520
|
+
* The environment still wins, because an explicitly paired app is a deliberate
|
|
5521
|
+
* configuration and not a cache to be second-guessed.
|
|
5522
|
+
*/
|
|
5258
5523
|
desktopToken = process.env.LOCAL_OPERATOR_DESKTOP_TOKEN || null;
|
|
5259
5524
|
/** Authenticated SSE relay for canonical session events. Recreated when the
|
|
5260
5525
|
* backend URL rotates (external-backend discovery) or the desktop token does
|
|
@@ -5447,15 +5712,51 @@ class BackendServiceManager {
|
|
|
5447
5712
|
this.feedRelay?.watchState(stateObserver);
|
|
5448
5713
|
}
|
|
5449
5714
|
requestDesktop(input) {
|
|
5450
|
-
return
|
|
5715
|
+
return requestDesktopOutcome(
|
|
5716
|
+
input,
|
|
5717
|
+
this.backendUrl,
|
|
5718
|
+
this.desktopToken
|
|
5719
|
+
).then(({ response, answered }) => {
|
|
5720
|
+
if (answered) this.noteTransportAnswer();
|
|
5721
|
+
return response;
|
|
5722
|
+
});
|
|
5451
5723
|
}
|
|
5452
5724
|
requestDesktopMedia(input, bytes) {
|
|
5453
|
-
return
|
|
5725
|
+
return requestDesktopMediaOutcome(
|
|
5454
5726
|
input,
|
|
5455
5727
|
bytes,
|
|
5456
5728
|
this.backendUrl,
|
|
5457
5729
|
this.desktopToken
|
|
5458
|
-
)
|
|
5730
|
+
).then(({ response, answered }) => {
|
|
5731
|
+
if (answered) this.noteTransportAnswer();
|
|
5732
|
+
return response;
|
|
5733
|
+
});
|
|
5734
|
+
}
|
|
5735
|
+
/**
|
|
5736
|
+
* Tell the state machine that this app's own request reached the daemon.
|
|
5737
|
+
*
|
|
5738
|
+
* `answered` is the TRANSPORT's verdict, not the promise's: every failure the
|
|
5739
|
+
* transport can produce resolves rather than throws, so `.then()` alone fires
|
|
5740
|
+
* for a refused socket, for a request this app never sent (no token, oversize,
|
|
5741
|
+
* an invalid op), and for one whose budget expired. Gating on it here is the
|
|
5742
|
+
* fix for review round 1's F-1, where an unearned stamp held the state at
|
|
5743
|
+
* `degraded` - which is not a state `checkBackendHealth` recovers from, so a
|
|
5744
|
+
* genuinely gone daemon could never be reported as gone.
|
|
5745
|
+
*
|
|
5746
|
+
* WHY this still counts EVERY answer, including a gated route's `401`/`403`:
|
|
5747
|
+
* the capability rules elsewhere in this file say a refusal is not a LIVENESS
|
|
5748
|
+
* signal, and that is about not calling it "connected". Turned around, a
|
|
5749
|
+
* refusal is the strongest liveness evidence this app has - a process read the
|
|
5750
|
+
* request and wrote a status line - which is exactly what a probe that ran out
|
|
5751
|
+
* of its 2 s budget failed to establish. Without this, one long agent turn was
|
|
5752
|
+
* enough to detach the connection and disable every read the daemon was still
|
|
5753
|
+
* answering.
|
|
5754
|
+
*
|
|
5755
|
+
* Only a state that actually moves pushes a snapshot: this runs on every
|
|
5756
|
+
* desktop call, and one IPC wake-up per request would be worse than the bug.
|
|
5757
|
+
*/
|
|
5758
|
+
noteTransportAnswer() {
|
|
5759
|
+
if (this.daemonState.recordTransportSuccess()) this.notifyStatus();
|
|
5459
5760
|
}
|
|
5460
5761
|
isAppClosing = false;
|
|
5461
5762
|
// Flag to track when the app is being closed
|
|
@@ -5494,6 +5795,7 @@ class BackendServiceManager {
|
|
|
5494
5795
|
packaged: electron.app.isPackaged
|
|
5495
5796
|
});
|
|
5496
5797
|
this.loadShellEnvironment();
|
|
5798
|
+
this.desktopToken = this.desktopToken || this.persistedDesktopToken();
|
|
5497
5799
|
logger.info(
|
|
5498
5800
|
`Backend Service Manager initialized. May spawn or kill: ${this.managerMaySpawn}`,
|
|
5499
5801
|
LogFileType.BACKEND
|
|
@@ -5768,6 +6070,11 @@ class BackendServiceManager {
|
|
|
5768
6070
|
kind: "heartbeat-stale",
|
|
5769
6071
|
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
6072
|
});
|
|
6073
|
+
} else if (this.answeredButUnusable) {
|
|
6074
|
+
this.daemonState.observe({
|
|
6075
|
+
kind: "unattachable",
|
|
6076
|
+
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.`
|
|
6077
|
+
});
|
|
5771
6078
|
} else {
|
|
5772
6079
|
this.daemonState.observe({
|
|
5773
6080
|
kind: "no-candidate",
|
|
@@ -5914,6 +6221,7 @@ class BackendServiceManager {
|
|
|
5914
6221
|
async adoptFirstUsableDaemon() {
|
|
5915
6222
|
if (this.isAppClosing) return false;
|
|
5916
6223
|
this.discoveryWedged = [];
|
|
6224
|
+
this.answeredButUnusable = null;
|
|
5917
6225
|
if (this.remoteConfigured) {
|
|
5918
6226
|
this.discoveryBlocksSpawn = true;
|
|
5919
6227
|
return await this.legacyFixedPortAdoption();
|
|
@@ -5996,19 +6304,40 @@ class BackendServiceManager {
|
|
|
5996
6304
|
return false;
|
|
5997
6305
|
}
|
|
5998
6306
|
}
|
|
5999
|
-
|
|
6307
|
+
const probe = await this.probeCandidate(candidate.address, token);
|
|
6308
|
+
if (probe.verdict === "refused") {
|
|
6000
6309
|
logger.info(
|
|
6001
|
-
`Daemon ${candidate.address}
|
|
6310
|
+
`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
6311
|
LogFileType.BACKEND
|
|
6003
6312
|
);
|
|
6004
6313
|
this.daemonState.observe({
|
|
6005
6314
|
kind: "capability",
|
|
6006
|
-
status:
|
|
6007
|
-
detail: `A daemon is running at ${candidate.address}, but it refused this app's credential for its desktop plane.`
|
|
6315
|
+
status: probe.status,
|
|
6316
|
+
detail: `A daemon is running at ${candidate.address}, but it refused this app's credential for its desktop plane (HTTP ${probe.status}).`
|
|
6317
|
+
});
|
|
6318
|
+
this.notifyStatus();
|
|
6319
|
+
return false;
|
|
6320
|
+
}
|
|
6321
|
+
if (probe.verdict === "unusable") {
|
|
6322
|
+
logger.info(
|
|
6323
|
+
`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.`,
|
|
6324
|
+
LogFileType.BACKEND
|
|
6325
|
+
);
|
|
6326
|
+
this.discoveryBlocksSpawn = true;
|
|
6327
|
+
this.answeredButUnusable = {
|
|
6328
|
+
address: candidate.address,
|
|
6329
|
+
status: probe.status
|
|
6330
|
+
};
|
|
6331
|
+
this.daemonState.observe({
|
|
6332
|
+
kind: "unattachable",
|
|
6333
|
+
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.`
|
|
6008
6334
|
});
|
|
6009
6335
|
this.notifyStatus();
|
|
6010
6336
|
return false;
|
|
6011
6337
|
}
|
|
6338
|
+
if (probe.verdict === "unreachable") {
|
|
6339
|
+
return false;
|
|
6340
|
+
}
|
|
6012
6341
|
await this.attachTo(candidate, token);
|
|
6013
6342
|
return true;
|
|
6014
6343
|
}
|
|
@@ -6024,16 +6353,17 @@ class BackendServiceManager {
|
|
|
6024
6353
|
* `this.desktopToken` because this runs BEFORE adoption: the candidate must
|
|
6025
6354
|
* be proved usable without the manager having committed to it.
|
|
6026
6355
|
*/
|
|
6027
|
-
async
|
|
6356
|
+
async probeCandidate(address, token) {
|
|
6028
6357
|
try {
|
|
6029
6358
|
const result = await requestDesktop(
|
|
6030
6359
|
{ op: "sessions.list", limit: 1 },
|
|
6031
6360
|
address,
|
|
6032
6361
|
token
|
|
6033
6362
|
);
|
|
6034
|
-
|
|
6363
|
+
const verdict = classifyDesktopAnswer(result.status);
|
|
6364
|
+
return verdict === "accepted" ? { verdict } : { verdict, status: result.status };
|
|
6035
6365
|
} catch {
|
|
6036
|
-
return
|
|
6366
|
+
return { verdict: "unreachable" };
|
|
6037
6367
|
}
|
|
6038
6368
|
}
|
|
6039
6369
|
/**
|
|
@@ -6139,7 +6469,7 @@ class BackendServiceManager {
|
|
|
6139
6469
|
const payload = await response.json().catch(() => null);
|
|
6140
6470
|
if (!await this.authenticatesAgainstBackend()) {
|
|
6141
6471
|
logger.info(
|
|
6142
|
-
"A backend answered health but
|
|
6472
|
+
"A backend answered health but did not accept this app's desktop credential; not adopting it.",
|
|
6143
6473
|
LogFileType.BACKEND
|
|
6144
6474
|
);
|
|
6145
6475
|
return false;
|
|
@@ -6187,7 +6517,7 @@ class BackendServiceManager {
|
|
|
6187
6517
|
op: "sessions.list",
|
|
6188
6518
|
limit: 1
|
|
6189
6519
|
});
|
|
6190
|
-
return result.status
|
|
6520
|
+
return classifyDesktopAnswer(result.status) === "accepted";
|
|
6191
6521
|
} catch {
|
|
6192
6522
|
return false;
|
|
6193
6523
|
}
|
|
@@ -6268,7 +6598,13 @@ class BackendServiceManager {
|
|
|
6268
6598
|
this.startHealthCheck();
|
|
6269
6599
|
return false;
|
|
6270
6600
|
}
|
|
6271
|
-
|
|
6601
|
+
const occupancy = await this.configuredOriginOccupancy();
|
|
6602
|
+
if (occupancy) {
|
|
6603
|
+
this.observeOriginOccupancy(occupancy);
|
|
6604
|
+
this.startHealthCheck();
|
|
6605
|
+
return false;
|
|
6606
|
+
}
|
|
6607
|
+
this.desktopToken = this.mintDesktopToken();
|
|
6272
6608
|
let captured = null;
|
|
6273
6609
|
try {
|
|
6274
6610
|
this.venvPath = managedVenvPath({
|
|
@@ -6700,11 +7036,142 @@ class BackendServiceManager {
|
|
|
6700
7036
|
`Backend health check response status: ${response.status}`,
|
|
6701
7037
|
LogFileType.BACKEND
|
|
6702
7038
|
);
|
|
7039
|
+
this.lastHealthFailure = null;
|
|
6703
7040
|
return response.ok;
|
|
6704
|
-
} catch (
|
|
7041
|
+
} catch (error) {
|
|
7042
|
+
this.lastHealthFailure = classifyUnreachable(error);
|
|
6705
7043
|
return false;
|
|
6706
7044
|
}
|
|
6707
7045
|
}
|
|
7046
|
+
/**
|
|
7047
|
+
* What is answering the address this app is configured to serve on, if
|
|
7048
|
+
* anything that stops it starting a daemon there.
|
|
7049
|
+
*
|
|
7050
|
+
* `null` means "start it": either the socket was refused (the one answer that
|
|
7051
|
+
* proves a port free), or something answered 200 without identifying as a Local
|
|
7052
|
+
* Operator daemon. The second arm is deliberate and narrow - see the spawn gate
|
|
7053
|
+
* in `startOwned()` - and the observation is logged either way. An answer whose
|
|
7054
|
+
* STATUS is not 200 is not in that arm: it is an occupant (review round 1,
|
|
7055
|
+
* F-2), and it returns a `silent` occupancy below rather than a licence to
|
|
7056
|
+
* spawn.
|
|
7057
|
+
*/
|
|
7058
|
+
async configuredOriginOccupancy() {
|
|
7059
|
+
const probe = await probeUnidentified(this.backendUrl, {
|
|
7060
|
+
timeoutMs: PROBE_TIMEOUT_MS$1
|
|
7061
|
+
});
|
|
7062
|
+
switch (probe.reason) {
|
|
7063
|
+
case "identity-mismatch":
|
|
7064
|
+
return {
|
|
7065
|
+
kind: "daemon",
|
|
7066
|
+
pid: probe.identity?.pid ?? null,
|
|
7067
|
+
version: probe.identity?.version ?? "",
|
|
7068
|
+
detail: probe.detail
|
|
7069
|
+
};
|
|
7070
|
+
/*
|
|
7071
|
+
* A status that is not 200 is an OCCUPANT, not a licence to spawn, and it
|
|
7072
|
+
* is handled here rather than by the `default` arm below so the decision is
|
|
7073
|
+
* the one a reader finds rather than the one the switch happens to fall
|
|
7074
|
+
* into (review round 1, F-2). It covers a Local Operator daemon starting up,
|
|
7075
|
+
* unhealthy or shutting down, and a proxy that fronts one and answers 5xx:
|
|
7076
|
+
* in every one of them the socket is bound, so a child spawned onto it dies
|
|
7077
|
+
* on `[Errno 48]` - after `mintDesktopToken()` has already overwritten the
|
|
7078
|
+
* credential for the daemon actually serving there. The answer does not
|
|
7079
|
+
* prove a daemon this app may attach to, so the gate declines to start one
|
|
7080
|
+
* and keeps probing; that is the safe direction, because the failure mode of
|
|
7081
|
+
* the other one is unrecoverable loss of the pairing token.
|
|
7082
|
+
*/
|
|
7083
|
+
case "unready-answer":
|
|
7084
|
+
return {
|
|
7085
|
+
kind: "silent",
|
|
7086
|
+
cause: "other",
|
|
7087
|
+
detail: probe.detail
|
|
7088
|
+
};
|
|
7089
|
+
case "not-a-daemon":
|
|
7090
|
+
logger.info(
|
|
7091
|
+
`${this.backendUrl}${HEALTH_PATH$1} answered and is not a Local Operator daemon (${probe.detail}); starting a daemon anyway, as this app always has.`,
|
|
7092
|
+
LogFileType.BACKEND
|
|
7093
|
+
);
|
|
7094
|
+
return null;
|
|
7095
|
+
case "unreachable":
|
|
7096
|
+
return probe.cause === "refused" ? null : {
|
|
7097
|
+
kind: "silent",
|
|
7098
|
+
cause: probe.cause ?? "other",
|
|
7099
|
+
detail: probe.detail
|
|
7100
|
+
};
|
|
7101
|
+
default:
|
|
7102
|
+
return {
|
|
7103
|
+
kind: "silent",
|
|
7104
|
+
cause: "other",
|
|
7105
|
+
detail: probe.detail
|
|
7106
|
+
};
|
|
7107
|
+
}
|
|
7108
|
+
}
|
|
7109
|
+
/**
|
|
7110
|
+
* Publish what the spawn gate saw, in the vocabulary the copy already uses.
|
|
7111
|
+
*
|
|
7112
|
+
* Two outcomes reach here and each gets the state that names it: a Local
|
|
7113
|
+
* Operator daemon this app may not drive is `unattachable` (state `wedged` - a
|
|
7114
|
+
* server IS running and this app did not attach to it, no banner claiming it is
|
|
7115
|
+
* offline), and an address that did not answer in time is `unanswered` -
|
|
7116
|
+
* `degraded`, usable, no banner, and still counted, because a budget that
|
|
7117
|
+
* expired is not evidence of absence either way.
|
|
7118
|
+
*/
|
|
7119
|
+
observeOriginOccupancy(occupancy) {
|
|
7120
|
+
const daemonLine = occupancy.kind === "daemon";
|
|
7121
|
+
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`;
|
|
7122
|
+
const detail = `${what}. It keeps probing for a server it can open.`;
|
|
7123
|
+
this.daemonState.observe(
|
|
7124
|
+
occupancy.kind === "silent" ? { kind: "unanswered", cause: occupancy.cause, detail } : { kind: "unattachable", detail }
|
|
7125
|
+
);
|
|
7126
|
+
logger.info(`Not spawning a daemon: ${detail}`, LogFileType.BACKEND);
|
|
7127
|
+
this.notifyStatus();
|
|
7128
|
+
}
|
|
7129
|
+
/**
|
|
7130
|
+
* The token governing the daemon this app spawned in a PREVIOUS run, read from
|
|
7131
|
+
* the 0600 file beside the app's other per-user state, or null.
|
|
7132
|
+
*
|
|
7133
|
+
* Read-only on purpose: a launch that has not spawned anything yet must not
|
|
7134
|
+
* create a credential, or "this app holds no token" (a first run, and the
|
|
7135
|
+
* honest reason to decline a daemon it cannot open) would become unreachable.
|
|
7136
|
+
*/
|
|
7137
|
+
persistedDesktopToken() {
|
|
7138
|
+
try {
|
|
7139
|
+
const stored = fs.readFileSync(this.desktopTokenFile(), "utf8").trim();
|
|
7140
|
+
return stored || null;
|
|
7141
|
+
} catch {
|
|
7142
|
+
return null;
|
|
7143
|
+
}
|
|
7144
|
+
}
|
|
7145
|
+
/**
|
|
7146
|
+
* Mint the token for a daemon this app is about to spawn, and persist it so the
|
|
7147
|
+
* next launch can re-attach to that daemon instead of starting a second one.
|
|
7148
|
+
*
|
|
7149
|
+
* A token that cannot be written still governs this run's child - failing to
|
|
7150
|
+
* start over a cache file would be the worse trade - and the log line names
|
|
7151
|
+
* the cost, which is the stranded daemon next launch.
|
|
7152
|
+
*/
|
|
7153
|
+
mintDesktopToken() {
|
|
7154
|
+
const token = node_crypto.randomBytes(32).toString("hex");
|
|
7155
|
+
try {
|
|
7156
|
+
const file = this.desktopTokenFile();
|
|
7157
|
+
fs.mkdirSync(path$1.dirname(file), { recursive: true, mode: 448 });
|
|
7158
|
+
fs.writeFileSync(file, token, { mode: 384 });
|
|
7159
|
+
} catch (error) {
|
|
7160
|
+
logger.warn(
|
|
7161
|
+
"Could not persist the desktop token; a daemon spawned now will not be re-attachable after a restart.",
|
|
7162
|
+
LogFileType.BACKEND,
|
|
7163
|
+
error
|
|
7164
|
+
);
|
|
7165
|
+
}
|
|
7166
|
+
return token;
|
|
7167
|
+
}
|
|
7168
|
+
desktopTokenPath = null;
|
|
7169
|
+
desktopTokenFile() {
|
|
7170
|
+
if (this.desktopTokenPath === null) {
|
|
7171
|
+
this.desktopTokenPath = path$1.join(this.appDataPath, DESKTOP_TOKEN_FILENAME);
|
|
7172
|
+
}
|
|
7173
|
+
return this.desktopTokenPath;
|
|
7174
|
+
}
|
|
6708
7175
|
/**
|
|
6709
7176
|
* One probe of the daemon this app is attached to.
|
|
6710
7177
|
*
|
|
@@ -6722,9 +7189,12 @@ class BackendServiceManager {
|
|
|
6722
7189
|
const expected = this.daemonState.expectedInstanceId();
|
|
6723
7190
|
if (!expected) {
|
|
6724
7191
|
const ok = await this.checkHealth();
|
|
6725
|
-
|
|
6726
|
-
|
|
6727
|
-
|
|
7192
|
+
if (ok) return { kind: "identified" };
|
|
7193
|
+
const failure = this.lastHealthFailure;
|
|
7194
|
+
return {
|
|
7195
|
+
kind: failure === null || failure === "refused" ? "failed" : "unanswered",
|
|
7196
|
+
cause: failure ?? "other",
|
|
7197
|
+
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
7198
|
};
|
|
6729
7199
|
}
|
|
6730
7200
|
const probe = await probeIdentity(this.backendUrl, expected, {
|
|
@@ -6747,8 +7217,12 @@ class BackendServiceManager {
|
|
|
6747
7217
|
detail: `${this.backendUrl} answered but is not a Local Operator daemon (${probe.detail})`
|
|
6748
7218
|
};
|
|
6749
7219
|
case "unreachable":
|
|
6750
|
-
return {
|
|
7220
|
+
return probe.cause === "refused" ? {
|
|
6751
7221
|
kind: "failed",
|
|
7222
|
+
detail: `${this.backendUrl} refused the connection (${probe.detail})`
|
|
7223
|
+
} : {
|
|
7224
|
+
kind: "unanswered",
|
|
7225
|
+
cause: probe.cause,
|
|
6752
7226
|
detail: `${this.backendUrl} did not answer (${probe.detail})`
|
|
6753
7227
|
};
|
|
6754
7228
|
}
|