local-operator-ui 0.25.9 → 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 +661 -118
- package/out/renderer/assets/{_basePickBy-DkEkh-PB.js → _basePickBy--Yue-XLQ.js} +1 -1
- package/out/renderer/assets/{_baseUniq-BoIvPFtq.js → _baseUniq-CX84XXMp.js} +1 -1
- package/out/renderer/assets/{agent-details-page-D42MOCs7.js → agent-details-page-BjoWEolx.js} +1 -1
- package/out/renderer/assets/{agent-hub-page-CdZeFDad.js → agent-hub-page-BuMeC5YA.js} +1 -1
- package/out/renderer/assets/{agents-page-DIxM0s-X.js → agents-page-DMGAWpXp.js} +2 -2
- package/out/renderer/assets/{arc-4ngVrwIY.js → arc-CR3HNaPp.js} +1 -1
- package/out/renderer/assets/{architectureDiagram-IEHRJDOE-CAnb4M8x.js → architectureDiagram-IEHRJDOE-DLZPKw96.js} +1 -1
- package/out/renderer/assets/{arrow-left-CtyOtdQO.js → arrow-left-CIBQH5Fe.js} +1 -1
- package/out/renderer/assets/{blockDiagram-JOT3LUYC-kESXFLy1.js → blockDiagram-JOT3LUYC-BGnNcmsO.js} +1 -1
- package/out/renderer/assets/browser-page-B02RKtfN.js +1 -0
- package/out/renderer/assets/{c4Diagram-VJAJSXHY-BLvRcM3G.js → c4Diagram-VJAJSXHY-Bu96GUil.js} +1 -1
- package/out/renderer/assets/channel-Dekw979G.js +1 -0
- package/out/renderer/assets/{chunk-4BMEZGHF-BzyGtS7u.js → chunk-4BMEZGHF-Bc2IBqRl.js} +1 -1
- package/out/renderer/assets/{chunk-A2AXSNBT-BEzWu4yw.js → chunk-A2AXSNBT-C7PMbmgd.js} +1 -1
- package/out/renderer/assets/{chunk-AEK57VVT-CSE7vmLv.js → chunk-AEK57VVT-Dy6-mgEo.js} +1 -1
- package/out/renderer/assets/{chunk-D6G4REZN-CDzazU3_.js → chunk-D6G4REZN-cBOMihDM.js} +1 -1
- package/out/renderer/assets/{chunk-RZ5BOZE2-C0Bt3Utz.js → chunk-RZ5BOZE2-DtZPVT7y.js} +1 -1
- package/out/renderer/assets/{chunk-XZIHB7SX-Bbr1A6Pp.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-Crjt0Ets.js → compact-pagination-F8MDF-Kl.js} +1 -1
- package/out/renderer/assets/{dagre-OKDRZEBW-DGp5rNt7.js → dagre-OKDRZEBW-NnFztN4b.js} +1 -1
- package/out/renderer/assets/{diagram-SSKATNLV-CZ9h9i9l.js → diagram-SSKATNLV-2BWtLWap.js} +1 -1
- package/out/renderer/assets/{diagram-VNBRO52H-9Ea1Q8eQ.js → diagram-VNBRO52H-CENgJuWn.js} +1 -1
- package/out/renderer/assets/{erDiagram-Q7BY3M3F-DxqjWHsj.js → erDiagram-Q7BY3M3F-CCc0_w5h.js} +1 -1
- package/out/renderer/assets/{flowDiagram-4HSFHLVR-MPXKVzGy.js → flowDiagram-4HSFHLVR-DBpVSzRM.js} +1 -1
- package/out/renderer/assets/{ganttDiagram-APWFNJXF-DzVIqVp-.js → ganttDiagram-APWFNJXF-mf6DXttE.js} +1 -1
- package/out/renderer/assets/{gitGraphDiagram-7IBYFJ6S-wxrYE11_.js → gitGraphDiagram-7IBYFJ6S-Bq8O1rxL.js} +1 -1
- package/out/renderer/assets/{graph-DN4_InFF.js → graph-qxmVeyvg.js} +1 -1
- package/out/renderer/assets/icon-BF__WH76.css +1 -0
- package/out/renderer/assets/{icon-BOPiVwJU.js → icon-CEd0ml4Y.js} +9 -9
- package/out/renderer/assets/{index-D6cm58iQ.js → index-4byN7Q2L.js} +1 -1
- package/out/renderer/assets/{index-B2uMY09U.js → index-CT_UJzBT.js} +1 -1
- package/out/renderer/assets/{index-VMcD7L9G.js → index-DLktHc2h.js} +359 -359
- package/out/renderer/assets/{infoDiagram-PH2N3AL5-BT9ZT33s.js → infoDiagram-PH2N3AL5-BCOEX5M1.js} +1 -1
- package/out/renderer/assets/{installer-D_dKCXm_.js → installer-BsQIORMk.js} +2 -2
- package/out/renderer/assets/{journeyDiagram-U35MCT3I-Drfp7Dx4.js → journeyDiagram-U35MCT3I-BeoAemLm.js} +1 -1
- package/out/renderer/assets/{kanban-definition-NDS4AKOZ-zrwtWfh3.js → kanban-definition-NDS4AKOZ-g2cpPHPh.js} +1 -1
- package/out/renderer/assets/{layout-C_NwCHZA.js → layout-BtO_FG0y.js} +1 -1
- package/out/renderer/assets/{legacy-agents-page-DaRJ4ssr.js → legacy-agents-page-CIz8TVKP.js} +1 -1
- package/out/renderer/assets/{mermaid.core-BrQV9aUN.js → mermaid.core-CdVpQ79S.js} +5 -5
- package/out/renderer/assets/{mindmap-definition-ALO5MXBD-BENJMVb7.js → mindmap-definition-ALO5MXBD-CHVbB4ku.js} +1 -1
- package/out/renderer/assets/{page-header-DpmrDCKb.js → page-header-BGvY3vKz.js} +1 -1
- package/out/renderer/assets/{parseISO-B3E0He4W.js → parseISO-BejL_jy7.js} +1 -1
- package/out/renderer/assets/{pieDiagram-IB7DONF6-Cj_MvCB6.js → pieDiagram-IB7DONF6-BBOrJKFD.js} +1 -1
- package/out/renderer/assets/{quadrantDiagram-7GDLP6J5-CSLboz_B.js → quadrantDiagram-7GDLP6J5-DSBlTbPc.js} +1 -1
- package/out/renderer/assets/{radar-MK3ICKWK-B5o09zzn.js → radar-MK3ICKWK-pZgkhoLd.js} +1 -1
- package/out/renderer/assets/{radient-auth-buttons-D3rHBFpG.js → radient-auth-buttons-BnXJwZfD.js} +1 -1
- package/out/renderer/assets/{requirementDiagram-KVF5MWMF-BClS8AfY.js → requirementDiagram-KVF5MWMF-BUV7_UZP.js} +1 -1
- package/out/renderer/assets/{sankeyDiagram-QLVOVGJD-7yhRK2Vp.js → sankeyDiagram-QLVOVGJD-DfvIpv82.js} +1 -1
- package/out/renderer/assets/{schedules-page-BAO3byEh.js → schedules-page-D0aF6oGQ.js} +1 -1
- package/out/renderer/assets/{sequenceDiagram-X6HHIX6F-D8YzpDkf.js → sequenceDiagram-X6HHIX6F-DFuvLBd0.js} +1 -1
- package/out/renderer/assets/{settings-page-D3pHslhI.js → settings-page-DrjsU9En.js} +6 -6
- package/out/renderer/assets/{shield-check-DLPbFvDD.js → shield-check-DusR2dvL.js} +1 -1
- package/out/renderer/assets/{stateDiagram-DGXRK772-D3vCf9jT.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-BmcDjvMo.js → timeline-definition-BDJGKUSR-De6scz66.js} +1 -1
- package/out/renderer/assets/{use-agent-like-mutation-5qH9Ra93.js → use-agent-like-mutation-CxJR8287.js} +1 -1
- package/out/renderer/assets/{xychartDiagram-VJFVF3MP-D6EwLz04.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 -3
- package/out/renderer/assets/browser-page-DURkioyd.js +0 -1
- package/out/renderer/assets/channel-Do5StXgL.js +0 -1
- package/out/renderer/assets/classDiagram-GIVACNV2-DXbjXCFm.js +0 -1
- package/out/renderer/assets/classDiagram-v2-COTLJTTW-DXbjXCFm.js +0 -1
- package/out/renderer/assets/clone-6x4kDBYj.js +0 -1
- package/out/renderer/assets/icon-C-eyP-uR.css +0 -1
- package/out/renderer/assets/stateDiagram-v2-YXO3MK2T-cJKsjNUG.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);
|
|
@@ -2107,13 +2187,18 @@ function pythonBytecodeCacheDir(userDataDir) {
|
|
|
2107
2187
|
}
|
|
2108
2188
|
function withPythonBytecodeCache(env, userDataDir) {
|
|
2109
2189
|
const existing = env.PYTHONPYCACHEPREFIX?.trim();
|
|
2110
|
-
const prefix =
|
|
2190
|
+
const prefix = isUsablePrefix(existing) ? existing : pythonBytecodeCacheDir(userDataDir);
|
|
2111
2191
|
return {
|
|
2112
2192
|
...env,
|
|
2113
2193
|
PYTHONPYCACHEPREFIX: prefix,
|
|
2114
2194
|
PYTHONDONTWRITEBYTECODE: "1"
|
|
2115
2195
|
};
|
|
2116
2196
|
}
|
|
2197
|
+
function isUsablePrefix(value) {
|
|
2198
|
+
if (!value) return false;
|
|
2199
|
+
if (!path$1.isAbsolute(value)) return false;
|
|
2200
|
+
return !insideAppBundle(value);
|
|
2201
|
+
}
|
|
2117
2202
|
const VENV_BYTECODE_GUARD_FILE = "sitecustomize.py";
|
|
2118
2203
|
const VENV_BYTECODE_GUARD_SENTINEL = "# Local Operator bytecode guard";
|
|
2119
2204
|
function venvBytecodeGuardSource() {
|
|
@@ -2310,8 +2395,6 @@ function isPythonBytecodePath(bundlePath, candidate) {
|
|
|
2310
2395
|
);
|
|
2311
2396
|
const separator = "/";
|
|
2312
2397
|
if (!path2.endsWith(".pyc")) return false;
|
|
2313
|
-
const segments = path2.split(separator);
|
|
2314
|
-
if (!segments.includes("__pycache__")) return false;
|
|
2315
2398
|
return dirs.some((dir) => path2.startsWith(`${dir}${separator}`));
|
|
2316
2399
|
}
|
|
2317
2400
|
function planPythonBytecodeHeal(bundlePath, violations) {
|
|
@@ -3619,6 +3702,8 @@ const apiConfig = {
|
|
|
3619
3702
|
baseUrl: backendConfig.VITE_LOCAL_OPERATOR_API_URL
|
|
3620
3703
|
};
|
|
3621
3704
|
const DEGRADED_AFTER_FAILURES = 3;
|
|
3705
|
+
const UNANSWERED_BEFORE_DETACHED = 9;
|
|
3706
|
+
const TRANSPORT_EVIDENCE_MS = 3e4;
|
|
3622
3707
|
const PROBE_INTERVAL_MS = 1e4;
|
|
3623
3708
|
const PROBE_TIMEOUT_MS$1 = 2e3;
|
|
3624
3709
|
const DETACHED_AFTER_MS = 9e4;
|
|
@@ -3639,11 +3724,16 @@ class DaemonStateMachine {
|
|
|
3639
3724
|
updatedAt;
|
|
3640
3725
|
detachedSince = null;
|
|
3641
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;
|
|
3642
3731
|
/** Adopt a validated daemon (`owned` says whether this app spawned it). */
|
|
3643
3732
|
attach(identity, options) {
|
|
3644
3733
|
this.identity = identity;
|
|
3645
3734
|
this.owned = options.owned;
|
|
3646
3735
|
this.failures = 0;
|
|
3736
|
+
this.unanswered = 0;
|
|
3647
3737
|
this.detachedSince = null;
|
|
3648
3738
|
this.backoffMs = REATTACH_BACKOFF_MS;
|
|
3649
3739
|
this.state = "attached";
|
|
@@ -3673,6 +3763,7 @@ class DaemonStateMachine {
|
|
|
3673
3763
|
switch (observation.kind) {
|
|
3674
3764
|
case "identified":
|
|
3675
3765
|
this.failures = 0;
|
|
3766
|
+
this.unanswered = 0;
|
|
3676
3767
|
this.capabilityStatus = null;
|
|
3677
3768
|
this.detachedSince = null;
|
|
3678
3769
|
this.backoffMs = REATTACH_BACKOFF_MS;
|
|
@@ -3698,11 +3789,20 @@ class DaemonStateMachine {
|
|
|
3698
3789
|
this.enterDetached(observation.detail);
|
|
3699
3790
|
} else {
|
|
3700
3791
|
this.state = "degraded";
|
|
3701
|
-
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")}`;
|
|
3702
3793
|
}
|
|
3703
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";
|
|
3801
|
+
}
|
|
3802
|
+
this.detail = observation.detail;
|
|
3803
|
+
break;
|
|
3704
3804
|
case "pid-dead":
|
|
3705
|
-
this.enterDetached("The daemon's process is gone.");
|
|
3805
|
+
this.enterDetached("The daemon's process is gone.", true);
|
|
3706
3806
|
break;
|
|
3707
3807
|
case "no-candidate":
|
|
3708
3808
|
this.enterDetached(observation.detail);
|
|
@@ -3711,7 +3811,96 @@ class DaemonStateMachine {
|
|
|
3711
3811
|
this.updatedAt = this.now();
|
|
3712
3812
|
return this.state;
|
|
3713
3813
|
}
|
|
3714
|
-
|
|
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
|
+
}
|
|
3715
3904
|
this.failures = DEGRADED_AFTER_FAILURES;
|
|
3716
3905
|
this.state = "detached";
|
|
3717
3906
|
if (this.detachedSince === null) this.detachedSince = this.now();
|
|
@@ -3767,6 +3956,13 @@ class DaemonStateMachine {
|
|
|
3767
3956
|
desktopAvailable: this.desktopAvailable,
|
|
3768
3957
|
failures: this.failures,
|
|
3769
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,
|
|
3770
3966
|
detail: this.detail,
|
|
3771
3967
|
updatedAt: this.updatedAt
|
|
3772
3968
|
};
|
|
@@ -3899,6 +4095,24 @@ function recordAddress(record) {
|
|
|
3899
4095
|
const host = record.host.includes(":") ? `[${record.host}]` : record.host;
|
|
3900
4096
|
return `http://${host}:${record.port}`;
|
|
3901
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
|
+
}
|
|
3902
4116
|
function readIdentity(payload) {
|
|
3903
4117
|
if (typeof payload !== "object" || payload === null) return null;
|
|
3904
4118
|
const result = payload.result;
|
|
@@ -3955,6 +4169,7 @@ async function probeIdentity(address, expectedInstanceId, options = {}) {
|
|
|
3955
4169
|
} catch (error) {
|
|
3956
4170
|
return {
|
|
3957
4171
|
outcome: "unreachable",
|
|
4172
|
+
cause: classifyUnreachable(error),
|
|
3958
4173
|
detail: error instanceof Error ? error.message : String(error)
|
|
3959
4174
|
};
|
|
3960
4175
|
}
|
|
@@ -4140,14 +4355,16 @@ async function probeUnidentified(address, options = {}) {
|
|
|
4140
4355
|
});
|
|
4141
4356
|
if (response.status !== 200) {
|
|
4142
4357
|
return {
|
|
4143
|
-
reason: "
|
|
4358
|
+
reason: "unready-answer",
|
|
4144
4359
|
detail: `${HEALTH_PATH$1} answered ${response.status} and no record describes this address`
|
|
4145
4360
|
};
|
|
4146
4361
|
}
|
|
4147
4362
|
const payload = await response.json().catch(() => null);
|
|
4148
|
-
|
|
4363
|
+
const identity = readIdentity(payload);
|
|
4364
|
+
if (identity) {
|
|
4149
4365
|
return {
|
|
4150
4366
|
reason: "identity-mismatch",
|
|
4367
|
+
identity,
|
|
4151
4368
|
detail: `a daemon answered but no serve record describes ${address}, so it cannot be proven to be the one this app found`
|
|
4152
4369
|
};
|
|
4153
4370
|
}
|
|
@@ -4158,6 +4375,7 @@ async function probeUnidentified(address, options = {}) {
|
|
|
4158
4375
|
} catch (error) {
|
|
4159
4376
|
return {
|
|
4160
4377
|
reason: "unreachable",
|
|
4378
|
+
cause: classifyUnreachable(error),
|
|
4161
4379
|
detail: error instanceof Error ? error.message : String(error)
|
|
4162
4380
|
};
|
|
4163
4381
|
}
|
|
@@ -5186,9 +5404,15 @@ const SHUTDOWN_TIMEOUT_DEFAULTS = {
|
|
|
5186
5404
|
};
|
|
5187
5405
|
const OWNED_STOP_WORST_MS = SHUTDOWN_TIMEOUT_DEFAULTS.normal + SHUTDOWN_TIMEOUT_DEFAULTS.force;
|
|
5188
5406
|
const READINESS_POLL_INTERVAL_MS = 1e3;
|
|
5407
|
+
const DESKTOP_TOKEN_FILENAME = "desktop-token";
|
|
5189
5408
|
const execPromise = node_util.promisify(node_child_process.exec);
|
|
5190
5409
|
const ENV_VAR_REGEX = /^([^=]+)=(.*)$/;
|
|
5191
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
|
+
}
|
|
5192
5416
|
class BackendServiceManager {
|
|
5193
5417
|
process = null;
|
|
5194
5418
|
isRunning = false;
|
|
@@ -5212,6 +5436,19 @@ class BackendServiceManager {
|
|
|
5212
5436
|
remoteConfigured = false;
|
|
5213
5437
|
/** A failed probe or unreadable record is not evidence that spawning is safe. */
|
|
5214
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;
|
|
5215
5452
|
/**
|
|
5216
5453
|
* Records discovery found alive but unresponsive (pid alive, heartbeat
|
|
5217
5454
|
* stopped). They are why no candidate exists AND why spawning is forbidden,
|
|
@@ -5223,6 +5460,14 @@ class BackendServiceManager {
|
|
|
5223
5460
|
nextRecoveryAt = 0;
|
|
5224
5461
|
appDataPath = electron.app.getPath("userData");
|
|
5225
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. */
|
|
5226
5471
|
healthCheckInterval = null;
|
|
5227
5472
|
/** The only process this manager may terminate. Null means it owns nothing,
|
|
5228
5473
|
* which is a reason to report "nothing to stop" - never to go looking. */
|
|
@@ -5252,6 +5497,29 @@ class BackendServiceManager {
|
|
|
5252
5497
|
shellEnv = {};
|
|
5253
5498
|
// External/dev backends may be explicitly paired through main's environment.
|
|
5254
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
|
+
*/
|
|
5255
5523
|
desktopToken = process.env.LOCAL_OPERATOR_DESKTOP_TOKEN || null;
|
|
5256
5524
|
/** Authenticated SSE relay for canonical session events. Recreated when the
|
|
5257
5525
|
* backend URL rotates (external-backend discovery) or the desktop token does
|
|
@@ -5444,15 +5712,51 @@ class BackendServiceManager {
|
|
|
5444
5712
|
this.feedRelay?.watchState(stateObserver);
|
|
5445
5713
|
}
|
|
5446
5714
|
requestDesktop(input) {
|
|
5447
|
-
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
|
+
});
|
|
5448
5723
|
}
|
|
5449
5724
|
requestDesktopMedia(input, bytes) {
|
|
5450
|
-
return
|
|
5725
|
+
return requestDesktopMediaOutcome(
|
|
5451
5726
|
input,
|
|
5452
5727
|
bytes,
|
|
5453
5728
|
this.backendUrl,
|
|
5454
5729
|
this.desktopToken
|
|
5455
|
-
)
|
|
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();
|
|
5456
5760
|
}
|
|
5457
5761
|
isAppClosing = false;
|
|
5458
5762
|
// Flag to track when the app is being closed
|
|
@@ -5491,6 +5795,7 @@ class BackendServiceManager {
|
|
|
5491
5795
|
packaged: electron.app.isPackaged
|
|
5492
5796
|
});
|
|
5493
5797
|
this.loadShellEnvironment();
|
|
5798
|
+
this.desktopToken = this.desktopToken || this.persistedDesktopToken();
|
|
5494
5799
|
logger.info(
|
|
5495
5800
|
`Backend Service Manager initialized. May spawn or kill: ${this.managerMaySpawn}`,
|
|
5496
5801
|
LogFileType.BACKEND
|
|
@@ -5765,6 +6070,11 @@ class BackendServiceManager {
|
|
|
5765
6070
|
kind: "heartbeat-stale",
|
|
5766
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.`
|
|
5767
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
|
+
});
|
|
5768
6078
|
} else {
|
|
5769
6079
|
this.daemonState.observe({
|
|
5770
6080
|
kind: "no-candidate",
|
|
@@ -5911,6 +6221,7 @@ class BackendServiceManager {
|
|
|
5911
6221
|
async adoptFirstUsableDaemon() {
|
|
5912
6222
|
if (this.isAppClosing) return false;
|
|
5913
6223
|
this.discoveryWedged = [];
|
|
6224
|
+
this.answeredButUnusable = null;
|
|
5914
6225
|
if (this.remoteConfigured) {
|
|
5915
6226
|
this.discoveryBlocksSpawn = true;
|
|
5916
6227
|
return await this.legacyFixedPortAdoption();
|
|
@@ -5993,19 +6304,40 @@ class BackendServiceManager {
|
|
|
5993
6304
|
return false;
|
|
5994
6305
|
}
|
|
5995
6306
|
}
|
|
5996
|
-
|
|
6307
|
+
const probe = await this.probeCandidate(candidate.address, token);
|
|
6308
|
+
if (probe.verdict === "refused") {
|
|
5997
6309
|
logger.info(
|
|
5998
|
-
`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).`,
|
|
5999
6311
|
LogFileType.BACKEND
|
|
6000
6312
|
);
|
|
6001
6313
|
this.daemonState.observe({
|
|
6002
6314
|
kind: "capability",
|
|
6003
|
-
status:
|
|
6004
|
-
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}).`
|
|
6005
6317
|
});
|
|
6006
6318
|
this.notifyStatus();
|
|
6007
6319
|
return false;
|
|
6008
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.`
|
|
6334
|
+
});
|
|
6335
|
+
this.notifyStatus();
|
|
6336
|
+
return false;
|
|
6337
|
+
}
|
|
6338
|
+
if (probe.verdict === "unreachable") {
|
|
6339
|
+
return false;
|
|
6340
|
+
}
|
|
6009
6341
|
await this.attachTo(candidate, token);
|
|
6010
6342
|
return true;
|
|
6011
6343
|
}
|
|
@@ -6021,16 +6353,17 @@ class BackendServiceManager {
|
|
|
6021
6353
|
* `this.desktopToken` because this runs BEFORE adoption: the candidate must
|
|
6022
6354
|
* be proved usable without the manager having committed to it.
|
|
6023
6355
|
*/
|
|
6024
|
-
async
|
|
6356
|
+
async probeCandidate(address, token) {
|
|
6025
6357
|
try {
|
|
6026
6358
|
const result = await requestDesktop(
|
|
6027
6359
|
{ op: "sessions.list", limit: 1 },
|
|
6028
6360
|
address,
|
|
6029
6361
|
token
|
|
6030
6362
|
);
|
|
6031
|
-
|
|
6363
|
+
const verdict = classifyDesktopAnswer(result.status);
|
|
6364
|
+
return verdict === "accepted" ? { verdict } : { verdict, status: result.status };
|
|
6032
6365
|
} catch {
|
|
6033
|
-
return
|
|
6366
|
+
return { verdict: "unreachable" };
|
|
6034
6367
|
}
|
|
6035
6368
|
}
|
|
6036
6369
|
/**
|
|
@@ -6136,7 +6469,7 @@ class BackendServiceManager {
|
|
|
6136
6469
|
const payload = await response.json().catch(() => null);
|
|
6137
6470
|
if (!await this.authenticatesAgainstBackend()) {
|
|
6138
6471
|
logger.info(
|
|
6139
|
-
"A backend answered health but
|
|
6472
|
+
"A backend answered health but did not accept this app's desktop credential; not adopting it.",
|
|
6140
6473
|
LogFileType.BACKEND
|
|
6141
6474
|
);
|
|
6142
6475
|
return false;
|
|
@@ -6184,7 +6517,7 @@ class BackendServiceManager {
|
|
|
6184
6517
|
op: "sessions.list",
|
|
6185
6518
|
limit: 1
|
|
6186
6519
|
});
|
|
6187
|
-
return result.status
|
|
6520
|
+
return classifyDesktopAnswer(result.status) === "accepted";
|
|
6188
6521
|
} catch {
|
|
6189
6522
|
return false;
|
|
6190
6523
|
}
|
|
@@ -6265,7 +6598,13 @@ class BackendServiceManager {
|
|
|
6265
6598
|
this.startHealthCheck();
|
|
6266
6599
|
return false;
|
|
6267
6600
|
}
|
|
6268
|
-
|
|
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();
|
|
6269
6608
|
let captured = null;
|
|
6270
6609
|
try {
|
|
6271
6610
|
this.venvPath = managedVenvPath({
|
|
@@ -6697,11 +7036,142 @@ class BackendServiceManager {
|
|
|
6697
7036
|
`Backend health check response status: ${response.status}`,
|
|
6698
7037
|
LogFileType.BACKEND
|
|
6699
7038
|
);
|
|
7039
|
+
this.lastHealthFailure = null;
|
|
6700
7040
|
return response.ok;
|
|
6701
|
-
} catch (
|
|
7041
|
+
} catch (error) {
|
|
7042
|
+
this.lastHealthFailure = classifyUnreachable(error);
|
|
6702
7043
|
return false;
|
|
6703
7044
|
}
|
|
6704
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
|
+
}
|
|
6705
7175
|
/**
|
|
6706
7176
|
* One probe of the daemon this app is attached to.
|
|
6707
7177
|
*
|
|
@@ -6719,9 +7189,12 @@ class BackendServiceManager {
|
|
|
6719
7189
|
const expected = this.daemonState.expectedInstanceId();
|
|
6720
7190
|
if (!expected) {
|
|
6721
7191
|
const ok = await this.checkHealth();
|
|
6722
|
-
|
|
6723
|
-
|
|
6724
|
-
|
|
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}`
|
|
6725
7198
|
};
|
|
6726
7199
|
}
|
|
6727
7200
|
const probe = await probeIdentity(this.backendUrl, expected, {
|
|
@@ -6744,8 +7217,12 @@ class BackendServiceManager {
|
|
|
6744
7217
|
detail: `${this.backendUrl} answered but is not a Local Operator daemon (${probe.detail})`
|
|
6745
7218
|
};
|
|
6746
7219
|
case "unreachable":
|
|
6747
|
-
return {
|
|
7220
|
+
return probe.cause === "refused" ? {
|
|
6748
7221
|
kind: "failed",
|
|
7222
|
+
detail: `${this.backendUrl} refused the connection (${probe.detail})`
|
|
7223
|
+
} : {
|
|
7224
|
+
kind: "unanswered",
|
|
7225
|
+
cause: probe.cause,
|
|
6749
7226
|
detail: `${this.backendUrl} did not answer (${probe.detail})`
|
|
6750
7227
|
};
|
|
6751
7228
|
}
|
|
@@ -8337,13 +8814,35 @@ class ApprovalStore {
|
|
|
8337
8814
|
/**
|
|
8338
8815
|
* Raise a request, or report that one already covers this caller.
|
|
8339
8816
|
*
|
|
8340
|
-
*
|
|
8341
|
-
*
|
|
8342
|
-
*
|
|
8343
|
-
*
|
|
8344
|
-
*
|
|
8345
|
-
*
|
|
8346
|
-
*
|
|
8817
|
+
* THE QUEUE IS A QUEUE, and this comment is the record of what changed. Until
|
|
8818
|
+
* this round `requestAccess` called `displaceLive` on every new request, which
|
|
8819
|
+
* cleared the WHOLE live queue and tombstoned it: one prompt slot,
|
|
8820
|
+
* replace-don't-queue, so a second agent's request destroyed the first agent's
|
|
8821
|
+
* (and the test said so plainly, `"one prompt slot, replace-don't-queue"`).
|
|
8822
|
+
* The design authority says the opposite — "the queue is FIFO with a 10-minute
|
|
8823
|
+
* async TTL" (design 9.1), which is why `access-queue.ts` ships a cap of 16 that
|
|
8824
|
+
* this host could never reach — and the operator asked for it by name ("allow a
|
|
8825
|
+
* queue"). So:
|
|
8826
|
+
*
|
|
8827
|
+
* 1. A repeat request for the same origin by the same requester is idempotent
|
|
8828
|
+
* with its ORIGINAL TTL. Resetting the TTL would let a polling agent extend
|
|
8829
|
+
* the window indefinitely.
|
|
8830
|
+
* 2. A live receipt answers without re-prompting (see the deny arm below), so a
|
|
8831
|
+
* user does not learn to click Allow to make the prompt stop.
|
|
8832
|
+
* 3. A request for a different origin, or from a different requester, is
|
|
8833
|
+
* APPENDED in `sequence` order and waits its turn.
|
|
8834
|
+
* 4. At the cap the OLDEST live entry is displaced, with a `superseded`
|
|
8835
|
+
* tombstone so the agent that raised it learns what happened rather than
|
|
8836
|
+
* timing out into an anonymous `none`. The NEWEST requester is never refused:
|
|
8837
|
+
* refusing the agent that is behaving normally would turn a full queue into a
|
|
8838
|
+
* hard, unactionable failure for the one caller that did nothing wrong.
|
|
8839
|
+
*
|
|
8840
|
+
* WHAT THE CHANGE COSTS, stated because it is a real trade a reviewer should not
|
|
8841
|
+
* have to find: a displaced request taught its agent immediately, where a queued
|
|
8842
|
+
* one now waits for its own `await_access` budget and then re-requests
|
|
8843
|
+
* (idempotent while its entry is live). In exchange, a user with four agents
|
|
8844
|
+
* working sees four requests instead of one, and no agent's request is destroyed
|
|
8845
|
+
* by another's arrival.
|
|
8347
8846
|
*/
|
|
8348
8847
|
requestAccess(rawUrl, requester, kind = "async", commandId) {
|
|
8349
8848
|
const url = safeHttpUrl(rawUrl);
|
|
@@ -8360,13 +8859,8 @@ class ApprovalStore {
|
|
|
8360
8859
|
}
|
|
8361
8860
|
this.sweep(now);
|
|
8362
8861
|
if (liveQueue(this.queue, now).length >= ACCESS_QUEUE_CAP) {
|
|
8363
|
-
|
|
8364
|
-
"access_queue_full",
|
|
8365
|
-
`site approval queue is full with ${liveQueue(this.queue, now).length} pending requests`,
|
|
8366
|
-
{ pending_count: liveQueue(this.queue, now).length }
|
|
8367
|
-
);
|
|
8862
|
+
this.displaceOldest(now);
|
|
8368
8863
|
}
|
|
8369
|
-
this.displaceLive(now);
|
|
8370
8864
|
const entry = newEntry(
|
|
8371
8865
|
url.origin,
|
|
8372
8866
|
displayAuthority(url),
|
|
@@ -8415,18 +8909,19 @@ class ApprovalStore {
|
|
|
8415
8909
|
requestedAt: entry.requestedAt,
|
|
8416
8910
|
expiresAt: entry.expiresAt
|
|
8417
8911
|
/*
|
|
8418
|
-
* NO `decision` TERM, and the removal is the point
|
|
8419
|
-
* `accessState()` takes an optional decision so
|
|
8420
|
-
* `allowed`/`denied` before the grant lands,
|
|
8421
|
-
* decision receipts — but every arm that
|
|
8422
|
-
* from the queue FIRST (`respond`
|
|
8423
|
-
*
|
|
8424
|
-
*
|
|
8425
|
-
*
|
|
8426
|
-
*
|
|
8427
|
-
*
|
|
8428
|
-
*
|
|
8429
|
-
*
|
|
8912
|
+
* NO `decision` TERM, and the removal is the point (review round 1,
|
|
8913
|
+
* finding 5). The vendored `accessState()` takes an optional decision so
|
|
8914
|
+
* that a LIVE record can read `allowed`/`denied` before the grant lands,
|
|
8915
|
+
* and this host fed it from the decision receipts — but every arm that
|
|
8916
|
+
* writes a receipt removes its entry from the queue FIRST (`respond`
|
|
8917
|
+
* filters, `cancelAccess` splices), entry ids are minted per entry and
|
|
8918
|
+
* nothing here is restored from disk, so a record with a decision can
|
|
8919
|
+
* never be the record this reads. It was unreachable code carrying the
|
|
8920
|
+
* residue of the defect this PR fixes, and a reader would reasonably infer
|
|
8921
|
+
* from it that a receipt can still answer. It cannot: `requestAccess`
|
|
8922
|
+
* answers from `liveAuthority` and from a durable verdict, and the
|
|
8923
|
+
* receipts are the store's record of what was decided (purged by
|
|
8924
|
+
* `revokeOrigin` and swept by TTL) rather than a reader's source.
|
|
8430
8925
|
*/
|
|
8431
8926
|
} : void 0,
|
|
8432
8927
|
this.tombstones,
|
|
@@ -8484,6 +8979,16 @@ class ApprovalStore {
|
|
|
8484
8979
|
);
|
|
8485
8980
|
}
|
|
8486
8981
|
const now = this.now();
|
|
8982
|
+
if (now >= entry.expiresAt) {
|
|
8983
|
+
this.queue = this.queue.filter(
|
|
8984
|
+
(candidate) => candidate.entryId !== entryId
|
|
8985
|
+
);
|
|
8986
|
+
this.onChanged();
|
|
8987
|
+
throw new BridgeCommandError(
|
|
8988
|
+
"internal",
|
|
8989
|
+
"that site approval request expired before it was answered; the agent has to ask again"
|
|
8990
|
+
);
|
|
8991
|
+
}
|
|
8487
8992
|
this.queue = this.queue.filter(
|
|
8488
8993
|
(candidate) => candidate.entryId !== entryId
|
|
8489
8994
|
);
|
|
@@ -8644,6 +9149,9 @@ class ApprovalStore {
|
|
|
8644
9149
|
delete this.store.origins[origin];
|
|
8645
9150
|
removed += 1;
|
|
8646
9151
|
}
|
|
9152
|
+
for (const key of Object.keys(this.results)) {
|
|
9153
|
+
if (this.results[key]?.origin === origin) delete this.results[key];
|
|
9154
|
+
}
|
|
8647
9155
|
if (this.sessionGrants.delete(origin)) removed += 1;
|
|
8648
9156
|
const before = this.store.records.length;
|
|
8649
9157
|
this.store.records = this.store.records.filter(
|
|
@@ -8701,13 +9209,17 @@ class ApprovalStore {
|
|
|
8701
9209
|
this.onChanged();
|
|
8702
9210
|
}
|
|
8703
9211
|
// ---- internals -----------------------------------------------------------
|
|
8704
|
-
/**
|
|
8705
|
-
*
|
|
9212
|
+
/** At the cap: the OLDEST live entry (or entries, if a queue was restored over
|
|
9213
|
+
* the cap) steps aside, and its requester gets a tombstone so its next poll
|
|
8706
9214
|
* reads "superseded" rather than the neutral "none". */
|
|
8707
|
-
|
|
9215
|
+
displaceOldest(now) {
|
|
8708
9216
|
const live = liveQueue(this.queue, now);
|
|
8709
9217
|
if (!live.length) return;
|
|
8710
|
-
|
|
9218
|
+
const displaced = live.slice(
|
|
9219
|
+
0,
|
|
9220
|
+
Math.max(1, live.length - ACCESS_QUEUE_CAP + 1)
|
|
9221
|
+
);
|
|
9222
|
+
for (const entry of displaced) {
|
|
8711
9223
|
this.tombstones[receiptKey(entry.origin, entry.requester)] = tombstoneFor(
|
|
8712
9224
|
{
|
|
8713
9225
|
origin: entry.origin,
|
|
@@ -8718,7 +9230,8 @@ class ApprovalStore {
|
|
|
8718
9230
|
}
|
|
8719
9231
|
);
|
|
8720
9232
|
}
|
|
8721
|
-
|
|
9233
|
+
const displacedIds = new Set(displaced.map((entry) => entry.entryId));
|
|
9234
|
+
this.queue = this.queue.filter((entry) => !displacedIds.has(entry.entryId));
|
|
8722
9235
|
this.trimTombstones();
|
|
8723
9236
|
}
|
|
8724
9237
|
trimTombstones() {
|
|
@@ -9147,45 +9660,56 @@ class CdpPool {
|
|
|
9147
9660
|
}
|
|
9148
9661
|
}
|
|
9149
9662
|
const CONSENT_TITLE = "Site approval needed";
|
|
9150
|
-
function consentBody(origin) {
|
|
9151
|
-
return `An agent wants to open ${origin}. Approve or deny it in the browser tab.`;
|
|
9663
|
+
function consentBody(count, origin) {
|
|
9664
|
+
return count === 1 ? `An agent wants to open ${origin}. Approve or deny it in the browser tab.` : `${count} site approvals are waiting. Approve or deny them in the browser tab.`;
|
|
9152
9665
|
}
|
|
9153
9666
|
class ConsentNotifier {
|
|
9154
9667
|
constructor(options) {
|
|
9155
9668
|
this.options = options;
|
|
9156
9669
|
}
|
|
9157
|
-
announced =
|
|
9670
|
+
announced = 0;
|
|
9158
9671
|
/**
|
|
9159
|
-
* Announce the pending
|
|
9672
|
+
* Announce a change in the size of the pending set — ONE banner per increase.
|
|
9160
9673
|
*
|
|
9161
|
-
*
|
|
9162
|
-
*
|
|
9163
|
-
*
|
|
9164
|
-
*
|
|
9674
|
+
* WHY A COUNT WATERMARK RATHER THAN A SET OF ANNOUNCED IDS, which is what this
|
|
9675
|
+
* was: with one prompt slot the set could hold at most one entry, and with a
|
|
9676
|
+
* real queue (`approvals.ts` no longer displaces) the old rule raised one
|
|
9677
|
+
* banner PER UNANNOUNCED ENTRY — up to 16 banners for one busy minute, against
|
|
9678
|
+
* a rule that exists to avoid interrupting the operator (design 9.2, 11.4).
|
|
9679
|
+
* The count of the live set is the thing the user can act on, so an increase is
|
|
9680
|
+
* the event and a banner is the whole response to it.
|
|
9681
|
+
*
|
|
9682
|
+
* WHAT THIS GIVES UP, deliberately: a request that replaces another without
|
|
9683
|
+
* changing the count (a displacement at the cap, a cancel plus an arrival in
|
|
9684
|
+
* one refresh) raises no second banner. The alternative is a banner per entry,
|
|
9685
|
+
* and the banner's job is to bring the user to the band — where the tray shows
|
|
9686
|
+
* the whole live set, numbered. The click names the OLDEST live entry, which is
|
|
9687
|
+
* the one the tray selects by default.
|
|
9165
9688
|
*/
|
|
9166
9689
|
announce(pending) {
|
|
9167
|
-
const
|
|
9168
|
-
|
|
9169
|
-
|
|
9170
|
-
|
|
9690
|
+
const count = pending.length;
|
|
9691
|
+
const increased = count > this.announced;
|
|
9692
|
+
this.announced = count;
|
|
9693
|
+
if (!increased) return;
|
|
9171
9694
|
if (this.options.show !== "focus") return;
|
|
9172
|
-
|
|
9173
|
-
|
|
9174
|
-
|
|
9175
|
-
|
|
9176
|
-
|
|
9177
|
-
|
|
9695
|
+
const create = this.options.createNotification;
|
|
9696
|
+
if (!create && !electron.Notification.isSupported()) return;
|
|
9697
|
+
const oldest = pending[0];
|
|
9698
|
+
if (!oldest) return;
|
|
9699
|
+
try {
|
|
9700
|
+
const notification = (create ?? ((options) => new electron.Notification(options)))(
|
|
9701
|
+
{
|
|
9178
9702
|
title: CONSENT_TITLE,
|
|
9179
|
-
body: consentBody(
|
|
9703
|
+
body: consentBody(count, oldest.origin),
|
|
9180
9704
|
silent: false
|
|
9181
|
-
}
|
|
9182
|
-
|
|
9183
|
-
|
|
9184
|
-
|
|
9185
|
-
|
|
9186
|
-
|
|
9187
|
-
)
|
|
9188
|
-
|
|
9705
|
+
}
|
|
9706
|
+
);
|
|
9707
|
+
notification.on("click", () => this.options.onAttention(oldest.entryId));
|
|
9708
|
+
notification.show();
|
|
9709
|
+
} catch (error) {
|
|
9710
|
+
this.options.log?.(
|
|
9711
|
+
`[browser] could not raise a consent banner: ${String(error)}`
|
|
9712
|
+
);
|
|
9189
9713
|
}
|
|
9190
9714
|
}
|
|
9191
9715
|
}
|
|
@@ -10718,6 +11242,16 @@ class BrowserHost {
|
|
|
10718
11242
|
titleForChrome(title) {
|
|
10719
11243
|
return title === "about:blank" ? "" : title;
|
|
10720
11244
|
}
|
|
11245
|
+
/** Whether a tab is loading right now, false for a tab that is gone or whose
|
|
11246
|
+
* webContents has already been destroyed. Guarded the same way `snapshot()`
|
|
11247
|
+
* guards its reads: a read that lands between destruction and the
|
|
11248
|
+
* `destroyed` handler would otherwise throw "Object has been destroyed" out of
|
|
11249
|
+
* an IPC handler and blank the whole strip instead of dropping one tab. */
|
|
11250
|
+
tabLoading(tabId) {
|
|
11251
|
+
const record = this.registry.get(tabId);
|
|
11252
|
+
if (!record || record.view.webContents.isDestroyed()) return false;
|
|
11253
|
+
return record.view.webContents.isLoading();
|
|
11254
|
+
}
|
|
10721
11255
|
chromeState() {
|
|
10722
11256
|
const activeRecord = this.registry.activeTab;
|
|
10723
11257
|
const active = activeRecord && !activeRecord.view.webContents.isDestroyed() ? activeRecord : null;
|
|
@@ -10734,7 +11268,16 @@ class BrowserHost {
|
|
|
10734
11268
|
// Per TAB, not only for the active one: a background tab whose load was
|
|
10735
11269
|
// refused has no other way to say so - its page area is blank and the
|
|
10736
11270
|
// band belongs to the active tab - so the strip carries the mark.
|
|
10737
|
-
failed: this.loadFailures.has(entry.tabId)
|
|
11271
|
+
failed: this.loadFailures.has(entry.tabId),
|
|
11272
|
+
// PER TAB, and it is the one projection field this feature adds. The strip
|
|
11273
|
+
// could only ever see the ACTIVE tab's loading state before (`loading`
|
|
11274
|
+
// below), so a background agent tab that is loading a page showed nothing
|
|
11275
|
+
// at all - and since an agent tab is created non-active and only the active
|
|
11276
|
+
// tab occupies the content rect (`registry.ts:492-503`), "nothing at all"
|
|
11277
|
+
// was the whole of what the user saw of an agent's work. Read from the
|
|
11278
|
+
// record rather than the snapshot: `snapshot()` is the diagnostic
|
|
11279
|
+
// projection shared with `status`/`tabs`, and this field is chrome.
|
|
11280
|
+
loading: this.tabLoading(entry.tabId)
|
|
10738
11281
|
})),
|
|
10739
11282
|
activeTabId: activeRecord?.tabId ?? null,
|
|
10740
11283
|
url: active ? active.view.webContents.getURL() : "",
|