local-operator-ui 0.30.28 → 0.30.29
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 +589 -55
- package/out/renderer/assets/{_basePickBy-DVGL4dyS.js → _basePickBy-B-NI55Qi.js} +1 -1
- package/out/renderer/assets/{_baseUniq-BSXxR5nj.js → _baseUniq-QqU-ovXp.js} +1 -1
- package/out/renderer/assets/{agent-details-page-C1L4BYPg.js → agent-details-page-DJVrrjGz.js} +1 -1
- package/out/renderer/assets/{agent-hub-page-KuHsq4as.js → agent-hub-page-DGc_yArZ.js} +1 -1
- package/out/renderer/assets/{agents-page-CMyFoTdV.js → agents-page-Bt1QxEpc.js} +2 -2
- package/out/renderer/assets/{arc-DYtCn3b7.js → arc-BD4X1YDX.js} +1 -1
- package/out/renderer/assets/{architectureDiagram-IEHRJDOE-DT3LiUFK.js → architectureDiagram-IEHRJDOE-D0a_Nf2k.js} +1 -1
- package/out/renderer/assets/{blockDiagram-JOT3LUYC-CeYb8oXA.js → blockDiagram-JOT3LUYC-UGbo6reS.js} +1 -1
- package/out/renderer/assets/{browser-page-DH0PUHZ6.js → browser-page-DWkbX95_.js} +1 -1
- package/out/renderer/assets/{browser-webauthn-prompt-D3cdsaO1.js → browser-webauthn-prompt-DNZXVAtn.js} +1 -1
- package/out/renderer/assets/{c4Diagram-VJAJSXHY-BfLZhnkq.js → c4Diagram-VJAJSXHY-LtoyzeNf.js} +1 -1
- package/out/renderer/assets/channel-DZPWjVFP.js +1 -0
- package/out/renderer/assets/{chunk-4BMEZGHF-XdpXDuYe.js → chunk-4BMEZGHF-DYdhqKcB.js} +1 -1
- package/out/renderer/assets/{chunk-A2AXSNBT-B55G-7NN.js → chunk-A2AXSNBT-BaXRjt-L.js} +1 -1
- package/out/renderer/assets/{chunk-AEK57VVT-Cvjg3Mip.js → chunk-AEK57VVT-BTlk0NKY.js} +1 -1
- package/out/renderer/assets/{chunk-D6G4REZN-B20z3IQR.js → chunk-D6G4REZN-L8pXaXu4.js} +1 -1
- package/out/renderer/assets/{chunk-RZ5BOZE2-CBx1LpGz.js → chunk-RZ5BOZE2-DlBmG3dv.js} +1 -1
- package/out/renderer/assets/{chunk-XZIHB7SX-DQ3yYz4L.js → chunk-XZIHB7SX-DQ99vLKP.js} +1 -1
- package/out/renderer/assets/{classDiagram-GIVACNV2-DhyBtCQr.js → classDiagram-GIVACNV2-BX-i0wvn.js} +1 -1
- package/out/renderer/assets/{classDiagram-v2-COTLJTTW-DhyBtCQr.js → classDiagram-v2-COTLJTTW-BX-i0wvn.js} +1 -1
- package/out/renderer/assets/clone-B458zF2X.js +1 -0
- package/out/renderer/assets/{compact-pagination-Ahq7SAdj.js → compact-pagination-Yze9c5sn.js} +1 -1
- package/out/renderer/assets/{dagre-OKDRZEBW-D51os3J2.js → dagre-OKDRZEBW-JWUAfKY8.js} +1 -1
- package/out/renderer/assets/{diagram-SSKATNLV-DiqlVRyZ.js → diagram-SSKATNLV-DYViy9gs.js} +1 -1
- package/out/renderer/assets/{diagram-VNBRO52H-wyOk-7rJ.js → diagram-VNBRO52H-BmbRwpMM.js} +1 -1
- package/out/renderer/assets/{erDiagram-Q7BY3M3F-ClwBz7Vl.js → erDiagram-Q7BY3M3F-4AVF78sA.js} +1 -1
- package/out/renderer/assets/{flowDiagram-4HSFHLVR-6vCU0UK_.js → flowDiagram-4HSFHLVR-CT6Umjpq.js} +1 -1
- package/out/renderer/assets/{ganttDiagram-APWFNJXF-oQ2A5p_K.js → ganttDiagram-APWFNJXF-Dk1Y08hY.js} +1 -1
- package/out/renderer/assets/{gitGraphDiagram-7IBYFJ6S-Jmz0NydS.js → gitGraphDiagram-7IBYFJ6S-0geYV52b.js} +1 -1
- package/out/renderer/assets/{graph-CZlT6mkf.js → graph-DdZ6i8JQ.js} +1 -1
- package/out/renderer/assets/{index-NY8u3QY3.js → index-CGqR7D8S.js} +1 -1
- package/out/renderer/assets/{index-BBIALF90.js → index-DAQIcg1R.js} +1 -1
- package/out/renderer/assets/{index-CMKwoZ6K.js → index-DECk1RkU.js} +392 -392
- package/out/renderer/assets/{infoDiagram-PH2N3AL5-CWdY5eB5.js → infoDiagram-PH2N3AL5-B823Dzuf.js} +1 -1
- package/out/renderer/assets/{journeyDiagram-U35MCT3I-CZN3Z4Do.js → journeyDiagram-U35MCT3I-ZAnEIMhZ.js} +1 -1
- package/out/renderer/assets/{kanban-definition-NDS4AKOZ-O78rt8N8.js → kanban-definition-NDS4AKOZ-C76KVhSZ.js} +1 -1
- package/out/renderer/assets/{layout-BCu2X7f9.js → layout-B7w4mI-2.js} +1 -1
- package/out/renderer/assets/{legacy-agents-page-V_ZM9450.js → legacy-agents-page-Cs5WxptN.js} +1 -1
- package/out/renderer/assets/{mermaid.core-CQECvCmy.js → mermaid.core-CzXJ6T_7.js} +5 -5
- package/out/renderer/assets/{mindmap-definition-ALO5MXBD-BYiKzv5D.js → mindmap-definition-ALO5MXBD-CrmEkNv0.js} +1 -1
- package/out/renderer/assets/{parseISO-DUtfRwUe.js → parseISO-BT8-3R9w.js} +1 -1
- package/out/renderer/assets/{pieDiagram-IB7DONF6-BK-dabrh.js → pieDiagram-IB7DONF6-jaP9Rhad.js} +1 -1
- package/out/renderer/assets/{quadrantDiagram-7GDLP6J5-DwNrRMPZ.js → quadrantDiagram-7GDLP6J5-D0uakfby.js} +1 -1
- package/out/renderer/assets/{radar-MK3ICKWK-Dwa-5BxU.js → radar-MK3ICKWK-BFkpguxO.js} +1 -1
- package/out/renderer/assets/{radient-auth-buttons-DPj8zZ0H.js → radient-auth-buttons-THSzcFBG.js} +1 -1
- package/out/renderer/assets/{requirementDiagram-KVF5MWMF-CAvi9bJA.js → requirementDiagram-KVF5MWMF-Dp9PokO5.js} +1 -1
- package/out/renderer/assets/{sankeyDiagram-QLVOVGJD-BIJv0fDy.js → sankeyDiagram-QLVOVGJD-BsoeL4Y7.js} +1 -1
- package/out/renderer/assets/{schedules-page-yik4dl4q.js → schedules-page-bUZKQ5r9.js} +1 -1
- package/out/renderer/assets/{sequenceDiagram-X6HHIX6F-so-gEOtr.js → sequenceDiagram-X6HHIX6F-Fg4Ar7iI.js} +1 -1
- package/out/renderer/assets/{settings-page-C85MzeE6.js → settings-page-QVevKIgf.js} +1 -1
- package/out/renderer/assets/{stateDiagram-DGXRK772-CI5xRVbz.js → stateDiagram-DGXRK772-BuwiXTGP.js} +1 -1
- package/out/renderer/assets/{stateDiagram-v2-YXO3MK2T-CGpOff_s.js → stateDiagram-v2-YXO3MK2T-C_0tdFKm.js} +1 -1
- package/out/renderer/assets/{timeline-definition-BDJGKUSR-Cr3Lo1sS.js → timeline-definition-BDJGKUSR-CBJfeBmR.js} +1 -1
- package/out/renderer/assets/{use-agent-like-mutation-CWE6shLn.js → use-agent-like-mutation-ChuwGRmd.js} +1 -1
- package/out/renderer/assets/{xychartDiagram-VJFVF3MP-CUwvyhxh.js → xychartDiagram-VJFVF3MP-Okgqap83.js} +1 -1
- package/out/renderer/index.html +1 -1
- package/package.json +3 -3
- package/out/renderer/assets/channel-CN16DSI1.js +0 -1
- package/out/renderer/assets/clone-C90IZifK.js +0 -1
package/out/main/index.js
CHANGED
|
@@ -47,6 +47,9 @@ const DAEMON_UNPAIRED = {
|
|
|
47
47
|
};
|
|
48
48
|
const BACKEND_STATUS_EVENT = "backend-status-changed";
|
|
49
49
|
const BACKEND_RECONNECT_CHANNEL = "backend-reconnect";
|
|
50
|
+
function isServerReachable(state) {
|
|
51
|
+
return state !== "detached" && state !== "wedged";
|
|
52
|
+
}
|
|
50
53
|
function relayNeedsRebuild(current, next) {
|
|
51
54
|
return current.url !== next.url || current.token !== next.token;
|
|
52
55
|
}
|
|
@@ -2056,6 +2059,65 @@ function readServeRecords(dir) {
|
|
|
2056
2059
|
}
|
|
2057
2060
|
return { files, problem: null };
|
|
2058
2061
|
}
|
|
2062
|
+
function recordsHolding(files, address, now) {
|
|
2063
|
+
if (!address) return [];
|
|
2064
|
+
const wanted = normaliseAddress(address) ?? address;
|
|
2065
|
+
const records = [];
|
|
2066
|
+
for (const entry of files) {
|
|
2067
|
+
if (!entry.record) continue;
|
|
2068
|
+
if (recordAddress(entry.record) !== wanted) continue;
|
|
2069
|
+
if (recordPidLiveness(entry.record, now) === "dead") continue;
|
|
2070
|
+
records.push(entry.record);
|
|
2071
|
+
}
|
|
2072
|
+
return records;
|
|
2073
|
+
}
|
|
2074
|
+
function addressHoldsLiveRecord(address, options = {}) {
|
|
2075
|
+
const { unreadable, records } = addressHolders(address, options);
|
|
2076
|
+
return unreadable || records.length > 0;
|
|
2077
|
+
}
|
|
2078
|
+
function anyRecordHoldsSomething(files, now) {
|
|
2079
|
+
return files.some(
|
|
2080
|
+
(entry) => !entry.record || recordPidLiveness(entry.record, now) !== "dead"
|
|
2081
|
+
);
|
|
2082
|
+
}
|
|
2083
|
+
function addressHolders(address, options = {}) {
|
|
2084
|
+
if (!address) return { unreadable: false, records: [] };
|
|
2085
|
+
const { files, problem } = readServeRecords(
|
|
2086
|
+
serveRunDir(options.env ?? process.env)
|
|
2087
|
+
);
|
|
2088
|
+
return {
|
|
2089
|
+
unreadable: problem === "unreadable",
|
|
2090
|
+
records: recordsHolding(files, address, (options.now ?? Date.now)())
|
|
2091
|
+
};
|
|
2092
|
+
}
|
|
2093
|
+
function listenerPidsOn(port) {
|
|
2094
|
+
if (!Number.isInteger(port) || port <= 0 || port > 65535) return [];
|
|
2095
|
+
try {
|
|
2096
|
+
const out = node_child_process.execFileSync(
|
|
2097
|
+
"lsof",
|
|
2098
|
+
["-nP", `-iTCP:${port}`, "-sTCP:LISTEN", "-t"],
|
|
2099
|
+
{ encoding: "utf8", timeout: 2e3 }
|
|
2100
|
+
);
|
|
2101
|
+
const pids = /* @__PURE__ */ new Set();
|
|
2102
|
+
for (const line of out.split("\n")) {
|
|
2103
|
+
const pid = Number.parseInt(line.trim(), 10);
|
|
2104
|
+
if (Number.isInteger(pid) && pid > 0) pids.add(pid);
|
|
2105
|
+
}
|
|
2106
|
+
return [...pids];
|
|
2107
|
+
} catch {
|
|
2108
|
+
return [];
|
|
2109
|
+
}
|
|
2110
|
+
}
|
|
2111
|
+
function readRecordForPid(pid, env = process.env) {
|
|
2112
|
+
if (!Number.isInteger(pid) || pid <= 0) return null;
|
|
2113
|
+
const file = path$1.join(serveRunDir(env), `${pid}.json`);
|
|
2114
|
+
try {
|
|
2115
|
+
const entry = parseRecord(JSON.parse(fs.readFileSync(file, "utf8")), file);
|
|
2116
|
+
return entry.record && entry.record.pid === pid ? entry.record : null;
|
|
2117
|
+
} catch {
|
|
2118
|
+
return null;
|
|
2119
|
+
}
|
|
2120
|
+
}
|
|
2059
2121
|
function pidLiveness(pid, options = {}) {
|
|
2060
2122
|
try {
|
|
2061
2123
|
process.kill(pid, 0);
|
|
@@ -2096,8 +2158,18 @@ function classifyRecord(record, now, liveness = recordPidLiveness(record, now))
|
|
|
2096
2158
|
return age > HEARTBEAT_TIMEOUT_MS ? "wedged" : "live";
|
|
2097
2159
|
}
|
|
2098
2160
|
function recordAddress(record) {
|
|
2099
|
-
|
|
2100
|
-
|
|
2161
|
+
return addressOf("http:", canonicalHost(record.host), record.port);
|
|
2162
|
+
}
|
|
2163
|
+
function canonicalHost(host) {
|
|
2164
|
+
const bare = host.startsWith("[") && host.endsWith("]") ? host.slice(1, -1) : host;
|
|
2165
|
+
const unqualified = bare.endsWith(".") ? bare.slice(0, -1) : bare;
|
|
2166
|
+
const lower = unqualified.toLowerCase();
|
|
2167
|
+
if (lower === "localhost") return "127.0.0.1";
|
|
2168
|
+
if (lower === "::1" || lower === "0:0:0:0:0:0:0:1") return "::1";
|
|
2169
|
+
return bare;
|
|
2170
|
+
}
|
|
2171
|
+
function addressOf(scheme, host, port) {
|
|
2172
|
+
return `${scheme}//${host.includes(":") ? `[${host}]` : host}:${port}`;
|
|
2101
2173
|
}
|
|
2102
2174
|
function classifyUnreachable(error) {
|
|
2103
2175
|
const name = error?.name;
|
|
@@ -2320,12 +2392,24 @@ async function discoverDaemons(options = {}) {
|
|
|
2320
2392
|
wedged,
|
|
2321
2393
|
noRecordsAtAll: files.length === 0,
|
|
2322
2394
|
// A dead PID is positive evidence; timeout, malformed JSON or a stopped
|
|
2323
|
-
// heartbeat is not. Do not spawn over a
|
|
2324
|
-
//
|
|
2395
|
+
// heartbeat is not. Do not spawn over an address a record says is held. The
|
|
2396
|
+
// liveness question is asked the way `registry.py::scan` asks it, so a
|
|
2325
2397
|
// zombie record is dead here too and cannot block spawning forever.
|
|
2326
|
-
|
|
2327
|
-
|
|
2328
|
-
|
|
2398
|
+
//
|
|
2399
|
+
// SCOPED TO THE CONFIGURED ADDRESS (2026-09-23). It used to be scoped to the
|
|
2400
|
+
// ROOT - any record anywhere in this config root with a non-dead pid - which
|
|
2401
|
+
// forbade the spawn onto the address this app was actually configured for
|
|
2402
|
+
// because something else in the root had a live record. `addressHoldsLiveRecord`
|
|
2403
|
+
// is the same predicate for the addresses this app may also spawn on, so the
|
|
2404
|
+
// per-address gate in `backend-service.ts` and this verdict cannot disagree.
|
|
2405
|
+
// A caller that names NO address gets the old root-wide answer instead
|
|
2406
|
+
// (`anyRecordHoldsSomething`): there is then no address this verdict can call
|
|
2407
|
+
// free, and answering "free" on no evidence is the direction this module never
|
|
2408
|
+
// takes. The unparseable record lives in that arm for the same reason - it
|
|
2409
|
+
// cannot be attributed to an address at all - and what it used to stand for, a
|
|
2410
|
+
// daemon holding a port and not answering, is now asked directly of the port by
|
|
2411
|
+
// the spawn gate's own occupancy probe.
|
|
2412
|
+
blocksSpawn: problem === "unreadable" || (configuredAddress === null ? anyRecordHoldsSomething(files, now()) : recordsHolding(files, configuredAddress, now()).length > 0)
|
|
2329
2413
|
};
|
|
2330
2414
|
for (const rejection of rejected) {
|
|
2331
2415
|
log(
|
|
@@ -2341,9 +2425,13 @@ function normaliseAddress(url) {
|
|
|
2341
2425
|
if (!url) return null;
|
|
2342
2426
|
try {
|
|
2343
2427
|
const parsed = new URL(url);
|
|
2344
|
-
|
|
2428
|
+
if (!parsed.hostname) return null;
|
|
2345
2429
|
const port = parsed.port || (parsed.protocol === "https:" ? "443" : "80");
|
|
2346
|
-
return
|
|
2430
|
+
return addressOf(
|
|
2431
|
+
parsed.protocol,
|
|
2432
|
+
canonicalHost(parsed.hostname),
|
|
2433
|
+
Number(port)
|
|
2434
|
+
);
|
|
2347
2435
|
} catch {
|
|
2348
2436
|
return null;
|
|
2349
2437
|
}
|
|
@@ -6290,6 +6378,17 @@ class DaemonStateMachine {
|
|
|
6290
6378
|
getUrl() {
|
|
6291
6379
|
return this.identity?.url ?? null;
|
|
6292
6380
|
}
|
|
6381
|
+
/**
|
|
6382
|
+
* The connection's own snapshot.
|
|
6383
|
+
*
|
|
6384
|
+
* IT RETURNS EVERY FIELD EXCEPT `addressSubstitution`, and the Omission is the
|
|
6385
|
+
* point rather than a type trick (agent round 2, R2-1): that field is DERIVED
|
|
6386
|
+
* from the address this app is actually on, so it is assembled by the manager
|
|
6387
|
+
* (`BackendServiceManager.getStatusSnapshot`) which is the only object that holds
|
|
6388
|
+
* both the configured address and the connection. A machine that could write it
|
|
6389
|
+
* here would be a second producer, which is exactly how the field came to claim a
|
|
6390
|
+
* fallback the app had already left.
|
|
6391
|
+
*/
|
|
6293
6392
|
snapshot() {
|
|
6294
6393
|
return {
|
|
6295
6394
|
state: this.state,
|
|
@@ -7302,6 +7401,110 @@ function readinessPollDelayMs(elapsedMs) {
|
|
|
7302
7401
|
return READINESS_POLL_INTERVAL_MS;
|
|
7303
7402
|
}
|
|
7304
7403
|
const DESKTOP_TOKEN_FILENAME = "desktop-token";
|
|
7404
|
+
function portOf(address) {
|
|
7405
|
+
try {
|
|
7406
|
+
const parsed = Number.parseInt(new URL(address).port, 10);
|
|
7407
|
+
return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
|
|
7408
|
+
} catch {
|
|
7409
|
+
return null;
|
|
7410
|
+
}
|
|
7411
|
+
}
|
|
7412
|
+
function installName(prefix) {
|
|
7413
|
+
return prefix ? path$1.basename(prefix) : "";
|
|
7414
|
+
}
|
|
7415
|
+
function describeStartedAt(startedAtMs) {
|
|
7416
|
+
const started = new Date(startedAtMs);
|
|
7417
|
+
if (started.toDateString() === (/* @__PURE__ */ new Date()).toDateString()) {
|
|
7418
|
+
return `started ${started.toLocaleTimeString(void 0, {
|
|
7419
|
+
hour: "numeric",
|
|
7420
|
+
minute: "2-digit"
|
|
7421
|
+
})}`;
|
|
7422
|
+
}
|
|
7423
|
+
return `started ${started.toLocaleDateString(void 0, {
|
|
7424
|
+
year: "numeric",
|
|
7425
|
+
month: "short",
|
|
7426
|
+
day: "numeric"
|
|
7427
|
+
})}`;
|
|
7428
|
+
}
|
|
7429
|
+
function describeOccupant(occupant) {
|
|
7430
|
+
const facts = [];
|
|
7431
|
+
if (occupant.pid !== null) {
|
|
7432
|
+
facts.push(
|
|
7433
|
+
occupant.pidSource === "listener" ? `pid ${occupant.pid} read off the listening socket` : `pid ${occupant.pid}`
|
|
7434
|
+
);
|
|
7435
|
+
}
|
|
7436
|
+
const install = occupant.installKind || installName(occupant.prefix);
|
|
7437
|
+
if (install) facts.push(install);
|
|
7438
|
+
if (occupant.version) facts.push(`v${occupant.version}`);
|
|
7439
|
+
if (occupant.startedAtMs !== null) {
|
|
7440
|
+
facts.push(describeStartedAt(occupant.startedAtMs));
|
|
7441
|
+
}
|
|
7442
|
+
if (occupant.desktopReadStatus !== null) {
|
|
7443
|
+
facts.push(
|
|
7444
|
+
classifyDesktopAnswer(occupant.desktopReadStatus) === "refused" ? `refused this app's credential for its desktop plane (HTTP ${occupant.desktopReadStatus})` : `answered this app's desktop read with HTTP ${occupant.desktopReadStatus}`
|
|
7445
|
+
);
|
|
7446
|
+
}
|
|
7447
|
+
return facts.length > 0 ? ` (${facts.join(", ")})` : "";
|
|
7448
|
+
}
|
|
7449
|
+
const HOLDER_CLASS = {
|
|
7450
|
+
daemon: {
|
|
7451
|
+
one: "is running a Local Operator daemon this app has no key for",
|
|
7452
|
+
many: "are running Local Operator daemons this app has no key for"
|
|
7453
|
+
},
|
|
7454
|
+
record: {
|
|
7455
|
+
one: "is listed as still running in this app's own records",
|
|
7456
|
+
many: "are listed as still running in this app's own records"
|
|
7457
|
+
}
|
|
7458
|
+
};
|
|
7459
|
+
function listAddresses(items) {
|
|
7460
|
+
if (items.length === 0) return "";
|
|
7461
|
+
if (items.length === 1) return items[0];
|
|
7462
|
+
return `${items.slice(0, -1).join(", ")} and ${items[items.length - 1]}`;
|
|
7463
|
+
}
|
|
7464
|
+
function describeHolder(occupancy) {
|
|
7465
|
+
return `${occupancy.occupant.address}${describeOccupant(occupancy.occupant)}`;
|
|
7466
|
+
}
|
|
7467
|
+
function describeHolders(refusals) {
|
|
7468
|
+
const fragments = [];
|
|
7469
|
+
for (const kind of ["daemon", "record"]) {
|
|
7470
|
+
const group = refusals.filter((occupancy) => occupancy.kind === kind);
|
|
7471
|
+
if (group.length === 0) continue;
|
|
7472
|
+
fragments.push(
|
|
7473
|
+
`${listAddresses(group.map(describeHolder))} ${HOLDER_CLASS[kind][group.length === 1 ? "one" : "many"]}`
|
|
7474
|
+
);
|
|
7475
|
+
}
|
|
7476
|
+
for (const occupancy of refusals) {
|
|
7477
|
+
if (occupancy.kind !== "silent") continue;
|
|
7478
|
+
fragments.push(
|
|
7479
|
+
`${describeHolder(occupancy)} did not answer this app's probe with anything it could use (${occupancy.detail})`
|
|
7480
|
+
);
|
|
7481
|
+
}
|
|
7482
|
+
if (refusals.some((occupancy) => occupancy.kind === "unreadable")) {
|
|
7483
|
+
fragments.push(
|
|
7484
|
+
"this app's own records could not be read, so no address it may serve on could be called free"
|
|
7485
|
+
);
|
|
7486
|
+
}
|
|
7487
|
+
return fragments.join("; ");
|
|
7488
|
+
}
|
|
7489
|
+
const holderCount = (refusals) => refusals.length;
|
|
7490
|
+
function reclaimClause(refusals) {
|
|
7491
|
+
const pids = refusals.map((occupancy) => occupancy.occupant.pid).filter((pid) => pid !== null);
|
|
7492
|
+
if (pids.length === 0) return "";
|
|
7493
|
+
const single = holderCount(refusals) === 1 && pids.length === 1;
|
|
7494
|
+
const command = single ? `lop services reclaim ${pids[0]}` : "lop services reclaim <pid>";
|
|
7495
|
+
const direct = single ? "it from the install that owns it" : "them from the installs that own them";
|
|
7496
|
+
return ` Stop ${direct} with ${command} (lop services status lists what is running).`;
|
|
7497
|
+
}
|
|
7498
|
+
function describeSpawnRefusal(refusals) {
|
|
7499
|
+
const named = refusals.filter((occupancy) => occupancy.kind !== "unreadable");
|
|
7500
|
+
const stopped = named.length === 0 ? "Nothing was started" : named.length === 1 ? "Nothing was started over it" : "Nothing was started over them";
|
|
7501
|
+
return `${describeHolders(refusals)}. ${stopped}.${reclaimClause(refusals)} It keeps probing for a server it can open.`;
|
|
7502
|
+
}
|
|
7503
|
+
const FALLBACK_SPAWN_URL = "http://127.0.0.1:8080";
|
|
7504
|
+
const NO_SUBSTITUTION_REASON = Object.freeze({
|
|
7505
|
+
holder: null,
|
|
7506
|
+
reclaim: null
|
|
7507
|
+
});
|
|
7305
7508
|
const execPromise = node_util.promisify(node_child_process.exec);
|
|
7306
7509
|
const ENV_VAR_REGEX = /^([^=]+)=(.*)$/;
|
|
7307
7510
|
function classifyDesktopAnswer(status2) {
|
|
@@ -7329,7 +7532,50 @@ class BackendServiceManager {
|
|
|
7329
7532
|
startupMode = "NOT_STARTED";
|
|
7330
7533
|
port;
|
|
7331
7534
|
backendUrl;
|
|
7535
|
+
/**
|
|
7536
|
+
* The address this app is CONFIGURED to serve on, which never rotates.
|
|
7537
|
+
*
|
|
7538
|
+
* `backendUrl` is where the app is TALKING - a daemon discovery adopted, or the
|
|
7539
|
+
* fallback address after a fallback spawn - so a spawn gate keyed on it would
|
|
7540
|
+
* ask "may I start a daemon here" about whatever the app happens to be attached
|
|
7541
|
+
* to, which is somebody else's daemon. The config address is a separate fact and
|
|
7542
|
+
* it is the one the gate needs.
|
|
7543
|
+
*/
|
|
7544
|
+
configuredUrl;
|
|
7545
|
+
/**
|
|
7546
|
+
* The addresses this app may spawn a managed daemon on after the configured one,
|
|
7547
|
+
* best first (`FALLBACK_SPAWN_URL` by default).
|
|
7548
|
+
*
|
|
7549
|
+
* A field rather than a bare constant so a rig can exercise the fallback on a
|
|
7550
|
+
* port the kernel picked, which is the same reason `DiscoverOptions.fetchImpl`
|
|
7551
|
+
* is injectable: proving this path must not require binding a fixed port on a
|
|
7552
|
+
* machine somebody else is using.
|
|
7553
|
+
*/
|
|
7554
|
+
fallbackSpawnUrls;
|
|
7555
|
+
/**
|
|
7556
|
+
* What stopped the last spawn attempt, when what stopped it was an address this
|
|
7557
|
+
* app may spawn on being held by something IT DOES NOT OWN. Null when nothing
|
|
7558
|
+
* did.
|
|
7559
|
+
*
|
|
7560
|
+
* Read by `index.ts` through `isStartBlockedByOccupiedAddress()`: a false return
|
|
7561
|
+
* from `start()` is the one place the app used to quit, and the whole point of
|
|
7562
|
+
* this field is that this class of failure is not a reason to take the app down.
|
|
7563
|
+
*/
|
|
7564
|
+
spawnRefusals = null;
|
|
7332
7565
|
remoteConfigured = false;
|
|
7566
|
+
/**
|
|
7567
|
+
* The last address this app served on that was NOT the one it is configured for,
|
|
7568
|
+
* and the reason the gate gave for moving there.
|
|
7569
|
+
*
|
|
7570
|
+
* NEITHER IS THE CLAIM - both are history. The claim itself is derived on every
|
|
7571
|
+
* snapshot from the address the connection is on (`addressSubstitutionFor`), so a
|
|
7572
|
+
* reason recorded here can only reach a surface while the app is genuinely on
|
|
7573
|
+
* that other address (agent round 2, R2-1/R2-2). That is what makes it safe to
|
|
7574
|
+
* write the reason at the moment the gate makes its decision, which is the only
|
|
7575
|
+
* moment the holder is observable at all.
|
|
7576
|
+
*/
|
|
7577
|
+
servedElsewhere = null;
|
|
7578
|
+
substitutionReason = NO_SUBSTITUTION_REASON;
|
|
7333
7579
|
/** A failed probe or unreadable record is not evidence that spawning is safe. */
|
|
7334
7580
|
discoveryBlocksSpawn = false;
|
|
7335
7581
|
/**
|
|
@@ -7540,7 +7786,98 @@ class BackendServiceManager {
|
|
|
7540
7786
|
* "I am not allowed" into "it is down".
|
|
7541
7787
|
*/
|
|
7542
7788
|
getStatusSnapshot() {
|
|
7543
|
-
|
|
7789
|
+
const snapshot2 = this.daemonState.snapshot();
|
|
7790
|
+
return {
|
|
7791
|
+
...snapshot2,
|
|
7792
|
+
addressSubstitution: this.addressSubstitutionFor(snapshot2)
|
|
7793
|
+
};
|
|
7794
|
+
}
|
|
7795
|
+
/**
|
|
7796
|
+
* Where this app is serving, relative to the address it is configured for.
|
|
7797
|
+
*
|
|
7798
|
+
* THE INVARIANT (agent round 2, R2-1): a snapshot's address claim is a FUNCTION
|
|
7799
|
+
* OF THE ADDRESS THE APP IS ACTUALLY USING, never of the last decision a gate
|
|
7800
|
+
* made. The first version of this field was written once by the spawn gate, and
|
|
7801
|
+
* that produced two wrong answers in opposite directions:
|
|
7802
|
+
*
|
|
7803
|
+
* - a launch that ATTACHES to a daemon discovered on another address (the
|
|
7804
|
+
* second launch of the incident - the fallback daemon is still there and the
|
|
7805
|
+
* persisted credential still opens it) records nothing, so the app was
|
|
7806
|
+
* silently on another address, which is the invisibility D1 exists to remove;
|
|
7807
|
+
* - a substitution recorded earlier was never retracted: once recovery attached
|
|
7808
|
+
* the app back to the configured address the record still said `serving:
|
|
7809
|
+
* <fallback>`, beside a snapshot whose own `url` was the configured address,
|
|
7810
|
+
* and its reclaim advice named a pid that may by then be the daemon the
|
|
7811
|
+
* operator is using.
|
|
7812
|
+
*
|
|
7813
|
+
* It also answers agent round 2's R2-2 for free: the claim is derived from
|
|
7814
|
+
* `isRunning` and the attached URL, so a start that never landed - a readiness
|
|
7815
|
+
* timeout, a throw, a stop - reports nothing at all, where a record written at
|
|
7816
|
+
* intent time announced a return over a dead backend.
|
|
7817
|
+
*
|
|
7818
|
+
* `holder`/`reclaim` are the one fact the derivation cannot recover, because the
|
|
7819
|
+
* gate is the only witness to what held the address; they are kept beside the
|
|
7820
|
+
* landing (`substitutionReasonFor`, which the landing publishes) and reach a surface
|
|
7821
|
+
* only through an arm
|
|
7822
|
+
* that is true. Null holder/reclaim is the honest state for a launch that adopted
|
|
7823
|
+
* somebody else's daemon: it never asked the configured address anything.
|
|
7824
|
+
*/
|
|
7825
|
+
addressSubstitutionFor(snapshot2) {
|
|
7826
|
+
const configured = normaliseAddress(this.configuredUrl);
|
|
7827
|
+
if (!configured || !snapshot2.url || !isServerReachable(snapshot2.state)) {
|
|
7828
|
+
return null;
|
|
7829
|
+
}
|
|
7830
|
+
const serving = normaliseAddress(snapshot2.url);
|
|
7831
|
+
if (!serving) return null;
|
|
7832
|
+
if (serving !== configured) {
|
|
7833
|
+
return {
|
|
7834
|
+
kind: "substituted",
|
|
7835
|
+
configured,
|
|
7836
|
+
serving,
|
|
7837
|
+
...this.substitutionReason
|
|
7838
|
+
};
|
|
7839
|
+
}
|
|
7840
|
+
return this.servedElsewhere ? { kind: "returned", configured, serving: this.servedElsewhere } : null;
|
|
7841
|
+
}
|
|
7842
|
+
/**
|
|
7843
|
+
* Adopt a validated daemon, and record WHERE the app landed and WHY it landed there.
|
|
7844
|
+
*
|
|
7845
|
+
* Every landing in this class goes through here - the spawn's registration and
|
|
7846
|
+
* both attach paths - which is what makes the claim a property of the app's
|
|
7847
|
+
* address rather than of one code path (agent round 2, R2-1). `servedElsewhere`
|
|
7848
|
+
* survives a landing on the configured address on purpose: it is what a return
|
|
7849
|
+
* has to name.
|
|
7850
|
+
*
|
|
7851
|
+
* THE REASON TRAVELS WITH THE LANDING rather than being written beside it (agent
|
|
7852
|
+
* round 3, R3-1). It used to be cleared here and re-recorded by the caller AFTER
|
|
7853
|
+
* this call returned, which meant the snapshot this landing pushed carried
|
|
7854
|
+
* `holder: null` for a fallback spawn - the ADOPTED arm's sentence about a daemon
|
|
7855
|
+
* the app had just started, and with the act withheld - until the next read. Now
|
|
7856
|
+
* the spawn path hands the reason in and the landing publishes it, so there is one
|
|
7857
|
+
* push per landing and it is the true one. The default is the empty pair, which is
|
|
7858
|
+
* what an attach path means: this app observed no holder because it never asked.
|
|
7859
|
+
*/
|
|
7860
|
+
attachDaemon(identity, options, reason = NO_SUBSTITUTION_REASON) {
|
|
7861
|
+
this.daemonState.attach(identity, options);
|
|
7862
|
+
const configured = normaliseAddress(this.configuredUrl);
|
|
7863
|
+
const landed = normaliseAddress(identity.url);
|
|
7864
|
+
if (landed && configured && landed !== configured) {
|
|
7865
|
+
this.servedElsewhere = landed;
|
|
7866
|
+
}
|
|
7867
|
+
this.substitutionReason = reason;
|
|
7868
|
+
}
|
|
7869
|
+
/**
|
|
7870
|
+
* The reason a fallback was taken, composed where the gate's refusals are still in
|
|
7871
|
+
* hand - and HANDED to the landing rather than written beside it (agent round 2,
|
|
7872
|
+
* R2-2 for the timing, agent round 3, R3-1 for the hand-off). A start that never
|
|
7873
|
+
* lands never carries this anywhere, and the claim is not there to render it
|
|
7874
|
+
* anyway.
|
|
7875
|
+
*/
|
|
7876
|
+
substitutionReasonFor(refusals) {
|
|
7877
|
+
return refusals.length > 0 ? {
|
|
7878
|
+
holder: describeHolders(refusals),
|
|
7879
|
+
reclaim: reclaimClause(refusals).trim() || null
|
|
7880
|
+
} : NO_SUBSTITUTION_REASON;
|
|
7544
7881
|
}
|
|
7545
7882
|
getStreamRelay() {
|
|
7546
7883
|
if (!this.streamRelay || this.streamRelayUrl !== this.backendUrl || this.streamRelayToken !== this.desktopToken) {
|
|
@@ -7720,6 +8057,7 @@ class BackendServiceManager {
|
|
|
7720
8057
|
*/
|
|
7721
8058
|
constructor(options = {}) {
|
|
7722
8059
|
this.userShellPath = options.userShellPath;
|
|
8060
|
+
this.fallbackSpawnUrls = options.fallbackSpawnUrls ?? [FALLBACK_SPAWN_URL];
|
|
7723
8061
|
try {
|
|
7724
8062
|
const apiUrl = new URL(backendConfig.VITE_LOCAL_OPERATOR_API_URL);
|
|
7725
8063
|
this.port = Number.parseInt(apiUrl.port, 10) || 1111;
|
|
@@ -7740,6 +8078,7 @@ class BackendServiceManager {
|
|
|
7740
8078
|
error
|
|
7741
8079
|
);
|
|
7742
8080
|
}
|
|
8081
|
+
this.configuredUrl = this.backendUrl;
|
|
7743
8082
|
this.venvPath = managedVenvPath({
|
|
7744
8083
|
platform: process.platform,
|
|
7745
8084
|
home: electron.app.getPath("home"),
|
|
@@ -8420,7 +8759,7 @@ class BackendServiceManager {
|
|
|
8420
8759
|
`Attached to daemon ${candidate.address} (pid ${candidate.record.pid}, v${candidate.identity.version}, ${candidate.record.install_kind || "kind unknown"}, record ${candidate.file})${previousUrl !== candidate.address ? ` - backend URL moved from ${previousUrl}` : ""}`,
|
|
8421
8760
|
LogFileType.BACKEND
|
|
8422
8761
|
);
|
|
8423
|
-
this.
|
|
8762
|
+
this.attachDaemon(
|
|
8424
8763
|
{
|
|
8425
8764
|
url: candidate.address,
|
|
8426
8765
|
instanceId: candidate.identity.instanceId,
|
|
@@ -8511,7 +8850,7 @@ class BackendServiceManager {
|
|
|
8511
8850
|
this.attachedRecord = null;
|
|
8512
8851
|
this.isExternalBackend = true;
|
|
8513
8852
|
this.startupMode = "EXISTING_SERVER";
|
|
8514
|
-
this.
|
|
8853
|
+
this.attachDaemon(
|
|
8515
8854
|
{
|
|
8516
8855
|
url: this.backendUrl,
|
|
8517
8856
|
instanceId: "",
|
|
@@ -8639,8 +8978,8 @@ class BackendServiceManager {
|
|
|
8639
8978
|
return true;
|
|
8640
8979
|
}
|
|
8641
8980
|
if (epoch !== this.startEpoch || this.isAppClosing) return false;
|
|
8642
|
-
if (!this.managerMaySpawn || this.remoteConfigured
|
|
8643
|
-
const noSpawnReason = this.managerMaySpawn ?
|
|
8981
|
+
if (!this.managerMaySpawn || this.remoteConfigured) {
|
|
8982
|
+
const noSpawnReason = this.managerMaySpawn ? `the configured target (${this.backendUrl}) is remote` : "VITE_DISABLE_BACKEND_MANAGER=true";
|
|
8644
8983
|
logger.info(
|
|
8645
8984
|
`No daemon discovered, and this app is configured not to spawn one (${noSpawnReason}).`,
|
|
8646
8985
|
LogFileType.BACKEND
|
|
@@ -8649,12 +8988,21 @@ class BackendServiceManager {
|
|
|
8649
8988
|
this.startHealthCheck();
|
|
8650
8989
|
return false;
|
|
8651
8990
|
}
|
|
8652
|
-
const
|
|
8653
|
-
|
|
8654
|
-
|
|
8991
|
+
const { target: target2, refusals } = await this.resolveSpawnTarget();
|
|
8992
|
+
this.spawnRefusals = target2 ? null : refusals;
|
|
8993
|
+
if (!target2) {
|
|
8994
|
+
this.observeSpawnRefusal(refusals);
|
|
8655
8995
|
this.startHealthCheck();
|
|
8656
8996
|
return false;
|
|
8657
8997
|
}
|
|
8998
|
+
this.port = target2.port;
|
|
8999
|
+
this.backendUrl = target2.address;
|
|
9000
|
+
if (refusals.length > 0) {
|
|
9001
|
+
logger.warn(
|
|
9002
|
+
`Starting a managed daemon on ${target2.address} instead: ${describeHolders(refusals)}.`,
|
|
9003
|
+
LogFileType.BACKEND
|
|
9004
|
+
);
|
|
9005
|
+
}
|
|
8658
9006
|
this.desktopToken = this.mintDesktopToken();
|
|
8659
9007
|
let captured = null;
|
|
8660
9008
|
try {
|
|
@@ -8723,7 +9071,8 @@ class BackendServiceManager {
|
|
|
8723
9071
|
break;
|
|
8724
9072
|
if (healthy) {
|
|
8725
9073
|
this.isRunning = true;
|
|
8726
|
-
|
|
9074
|
+
const reason = this.substitutionReasonFor(refusals);
|
|
9075
|
+
await this.registerOwnedDaemon(child, reason);
|
|
8727
9076
|
this.startHealthCheck();
|
|
8728
9077
|
this.notifyBackendReady();
|
|
8729
9078
|
return true;
|
|
@@ -8788,7 +9137,7 @@ class BackendServiceManager {
|
|
|
8788
9137
|
* second arm is the case of an install predating the record format, which is
|
|
8789
9138
|
* reachable on a fixed port exactly as the deprecated adoption path allows.
|
|
8790
9139
|
*/
|
|
8791
|
-
async registerOwnedDaemon(child) {
|
|
9140
|
+
async registerOwnedDaemon(child, reason) {
|
|
8792
9141
|
const identity = await this.ownedDaemonIdentity(child);
|
|
8793
9142
|
if (!identity) {
|
|
8794
9143
|
logger.warn(
|
|
@@ -8797,7 +9146,7 @@ class BackendServiceManager {
|
|
|
8797
9146
|
);
|
|
8798
9147
|
return;
|
|
8799
9148
|
}
|
|
8800
|
-
this.
|
|
9149
|
+
this.attachDaemon(identity, { owned: true }, reason);
|
|
8801
9150
|
this.daemonState.setPairing(DAEMON_PAIRED);
|
|
8802
9151
|
this.notifyStatus();
|
|
8803
9152
|
logger.info(
|
|
@@ -9106,16 +9455,15 @@ class BackendServiceManager {
|
|
|
9106
9455
|
* F-2), and it returns a `silent` occupancy below rather than a licence to
|
|
9107
9456
|
* spawn.
|
|
9108
9457
|
*/
|
|
9109
|
-
async configuredOriginOccupancy() {
|
|
9110
|
-
const probe = await probeUnidentified(
|
|
9458
|
+
async configuredOriginOccupancy(address) {
|
|
9459
|
+
const probe = await probeUnidentified(address, {
|
|
9111
9460
|
timeoutMs: PROBE_TIMEOUT_MS
|
|
9112
9461
|
});
|
|
9113
9462
|
switch (probe.reason) {
|
|
9114
9463
|
case "identity-mismatch":
|
|
9115
9464
|
return {
|
|
9116
9465
|
kind: "daemon",
|
|
9117
|
-
|
|
9118
|
-
version: probe.identity?.version ?? "",
|
|
9466
|
+
occupant: this.occupantOf(address, probe.identity ?? null),
|
|
9119
9467
|
detail: probe.detail
|
|
9120
9468
|
};
|
|
9121
9469
|
/*
|
|
@@ -9135,11 +9483,12 @@ class BackendServiceManager {
|
|
|
9135
9483
|
return {
|
|
9136
9484
|
kind: "silent",
|
|
9137
9485
|
cause: "other",
|
|
9486
|
+
occupant: this.occupantOf(address, null),
|
|
9138
9487
|
detail: probe.detail
|
|
9139
9488
|
};
|
|
9140
9489
|
case "not-a-daemon":
|
|
9141
9490
|
logger.info(
|
|
9142
|
-
`${
|
|
9491
|
+
`${address}${HEALTH_PATH$1} answered and is not a Local Operator daemon (${probe.detail}); starting a daemon anyway, as this app always has.`,
|
|
9143
9492
|
LogFileType.BACKEND
|
|
9144
9493
|
);
|
|
9145
9494
|
return null;
|
|
@@ -9147,32 +9496,203 @@ class BackendServiceManager {
|
|
|
9147
9496
|
return probe.cause === "refused" ? null : {
|
|
9148
9497
|
kind: "silent",
|
|
9149
9498
|
cause: probe.cause ?? "other",
|
|
9499
|
+
occupant: this.occupantOf(address, null),
|
|
9150
9500
|
detail: probe.detail
|
|
9151
9501
|
};
|
|
9152
9502
|
default:
|
|
9153
9503
|
return {
|
|
9154
9504
|
kind: "silent",
|
|
9155
9505
|
cause: "other",
|
|
9506
|
+
occupant: this.occupantOf(address, null),
|
|
9156
9507
|
detail: probe.detail
|
|
9157
9508
|
};
|
|
9158
9509
|
}
|
|
9159
9510
|
}
|
|
9511
|
+
/**
|
|
9512
|
+
* One address's holder, from the facts already in hand.
|
|
9513
|
+
*
|
|
9514
|
+
* WHERE EACH FACT COMES FROM, because the copy has to be able to say it. The
|
|
9515
|
+
* occupant's own `/health` is asked first and is the only source that proves who
|
|
9516
|
+
* holds the address; when it did not answer with an identity - a timeout, a
|
|
9517
|
+
* non-200, a body with no `instance_id` - the LISTENING SOCKET is asked instead
|
|
9518
|
+
* (`listenerPidsOn`), and `pidSource` carries which of the two produced the pid
|
|
9519
|
+
* so the sentence reports a kernel fact as a kernel fact.
|
|
9520
|
+
*
|
|
9521
|
+
* The record lookup goes the other way round: it is keyed by the pid, so it adds
|
|
9522
|
+
* `started_at` and the published install to whichever source named the holder.
|
|
9523
|
+
* A holder that ALSO has a record in this app's own root is usually one of this
|
|
9524
|
+
* app's own daemons from an earlier run, and that is worth saying out loud.
|
|
9525
|
+
*/
|
|
9526
|
+
occupantOf(address, identity) {
|
|
9527
|
+
let pid = identity && Number.isInteger(identity.pid) && identity.pid > 0 ? identity.pid : null;
|
|
9528
|
+
let pidSource = pid === null ? null : "answer";
|
|
9529
|
+
if (pid === null) {
|
|
9530
|
+
const port = portOf(address);
|
|
9531
|
+
const [found] = port === null ? [] : listenerPidsOn(port);
|
|
9532
|
+
if (found !== void 0) {
|
|
9533
|
+
pid = found;
|
|
9534
|
+
pidSource = "listener";
|
|
9535
|
+
}
|
|
9536
|
+
}
|
|
9537
|
+
const record = pid === null ? null : readRecordForPid(pid);
|
|
9538
|
+
return {
|
|
9539
|
+
address,
|
|
9540
|
+
pid,
|
|
9541
|
+
pidSource,
|
|
9542
|
+
version: identity?.version || record?.version || "",
|
|
9543
|
+
prefix: identity?.prefix || record?.prefix || "",
|
|
9544
|
+
installKind: identity?.installKind || record?.install_kind || "",
|
|
9545
|
+
startedAtMs: record ? record.started_at * 1e3 : null,
|
|
9546
|
+
// Probe-side occupancy never carries this: `configuredOriginOccupancy` is
|
|
9547
|
+
// asked about an address the attach path did not just read, and inventing
|
|
9548
|
+
// a status here would be the same class of guess the record arm avoids.
|
|
9549
|
+
desktopReadStatus: null
|
|
9550
|
+
};
|
|
9551
|
+
}
|
|
9552
|
+
/**
|
|
9553
|
+
* The addresses this app may spawn a managed daemon on, best first.
|
|
9554
|
+
*
|
|
9555
|
+
* The configured address leads, so a fallback spawn does not make the app forget
|
|
9556
|
+
* where it was told to serve: `backendUrl` rotates to wherever the app ended up,
|
|
9557
|
+
* while this list is recomputed from the configuration on every attempt. The rest
|
|
9558
|
+
* are the fallback budget, de-duplicated by `normaliseAddress` so a configuration
|
|
9559
|
+
* that already names 8080 does not probe one address twice - and the de-dup reads
|
|
9560
|
+
* through `canonicalHost`, so a configuration naming `localhost` is not probed a
|
|
9561
|
+
* second time as `127.0.0.1` (review round 1, R1-6: this comment asserted that
|
|
9562
|
+
* folding before `normaliseAddress` did it, which is how the assertion and the
|
|
9563
|
+
* comparison could disagree without either looking wrong).
|
|
9564
|
+
*/
|
|
9565
|
+
spawnAddresses() {
|
|
9566
|
+
const addresses = [this.configuredUrl, ...this.fallbackSpawnUrls];
|
|
9567
|
+
const seen = /* @__PURE__ */ new Set();
|
|
9568
|
+
const allowed = [];
|
|
9569
|
+
for (const address of addresses) {
|
|
9570
|
+
const normalised = normaliseAddress(address);
|
|
9571
|
+
if (!normalised || seen.has(normalised)) continue;
|
|
9572
|
+
if (portOf(normalised) === null) continue;
|
|
9573
|
+
seen.add(normalised);
|
|
9574
|
+
allowed.push(normalised);
|
|
9575
|
+
}
|
|
9576
|
+
return allowed;
|
|
9577
|
+
}
|
|
9578
|
+
/**
|
|
9579
|
+
* Record where this attempt's daemon is going, relative to the address this app
|
|
9580
|
+
* is configured for (design round 1, D1).
|
|
9581
|
+
*
|
|
9582
|
+
* WHY THE SNAPSHOT AND NOT ONLY THE LOG LINE. Measured on this change: serving on
|
|
9583
|
+
* the fallback address was pixel-for-pixel invisible - the "attached on 8080" and
|
|
9584
|
+
* "attached on 1111" frames of the whole surface hashed identically - and the
|
|
9585
|
+
* operator's own question in that state had no in-product answer while they
|
|
9586
|
+
* worked. What is recorded here is what the band renders.
|
|
9587
|
+
*
|
|
9588
|
+
/**
|
|
9589
|
+
* The first address this attempt may actually start a daemon on, and what the
|
|
9590
|
+
* gate found on every address that refused.
|
|
9591
|
+
*
|
|
9592
|
+
* TWO GATES PER ADDRESS, and each answers a different question. The record gate
|
|
9593
|
+
* (`addressHoldsLiveRecord`) is "does this app's own registry say a process holds
|
|
9594
|
+
* this address": its subject is a pid, and it needs no network. The occupancy
|
|
9595
|
+
* probe is "would a child of mine be able to bind this address", asked of the
|
|
9596
|
+
* address itself - and it exists precisely because the two came apart on the
|
|
9597
|
+
* operator's machine, where discovery had no record to work with, the app spawned
|
|
9598
|
+
* onto a port a daemon was already serving, and the child died on `[Errno 48]`
|
|
9599
|
+
* every ~10 s (see the comment at the call site).
|
|
9600
|
+
*
|
|
9601
|
+
* ORDER MATTERS and it is the record gate first: a live record for the address
|
|
9602
|
+
* means the address is held by something whose pid we can already name, and
|
|
9603
|
+
* probing it would only add an answer to a question already answered.
|
|
9604
|
+
*/
|
|
9605
|
+
async resolveSpawnTarget() {
|
|
9606
|
+
const refusals = [];
|
|
9607
|
+
for (const address of this.spawnAddresses()) {
|
|
9608
|
+
const port = portOf(address);
|
|
9609
|
+
if (port === null) continue;
|
|
9610
|
+
if (addressHoldsLiveRecord(address)) {
|
|
9611
|
+
const { unreadable, records } = addressHolders(address);
|
|
9612
|
+
if (unreadable) {
|
|
9613
|
+
refusals.push({
|
|
9614
|
+
kind: "unreadable",
|
|
9615
|
+
occupant: {
|
|
9616
|
+
address,
|
|
9617
|
+
pid: null,
|
|
9618
|
+
pidSource: null,
|
|
9619
|
+
version: "",
|
|
9620
|
+
prefix: "",
|
|
9621
|
+
installKind: "",
|
|
9622
|
+
startedAtMs: null,
|
|
9623
|
+
desktopReadStatus: null
|
|
9624
|
+
},
|
|
9625
|
+
detail: "this app's own records could not be read, so no address it may serve on could be called free"
|
|
9626
|
+
});
|
|
9627
|
+
continue;
|
|
9628
|
+
}
|
|
9629
|
+
const record = records[0] ?? null;
|
|
9630
|
+
const observed = this.answeredButUnusable?.address === address ? this.answeredButUnusable.status : null;
|
|
9631
|
+
refusals.push({
|
|
9632
|
+
kind: "record",
|
|
9633
|
+
occupant: {
|
|
9634
|
+
address,
|
|
9635
|
+
pid: record?.pid ?? null,
|
|
9636
|
+
// The record is this app's own file about its own child, so the pid
|
|
9637
|
+
// is this app's own answer rather than a socket's.
|
|
9638
|
+
pidSource: record ? "answer" : null,
|
|
9639
|
+
version: record?.version ?? "",
|
|
9640
|
+
prefix: record?.prefix ?? "",
|
|
9641
|
+
installKind: record?.install_kind ?? "",
|
|
9642
|
+
startedAtMs: record ? record.started_at * 1e3 : null,
|
|
9643
|
+
desktopReadStatus: observed
|
|
9644
|
+
},
|
|
9645
|
+
/*
|
|
9646
|
+
* Not user-facing: the record arm is rendered by `HOLDER_CLASS.record`, which
|
|
9647
|
+
* words the fact for a reader. This is the diagnostic beside it, for a log or
|
|
9648
|
+
* a debugger, so it keeps the module's own nouns.
|
|
9649
|
+
*/
|
|
9650
|
+
detail: `a serve record names ${address} with a pid that is not proven dead`
|
|
9651
|
+
});
|
|
9652
|
+
continue;
|
|
9653
|
+
}
|
|
9654
|
+
const occupancy = await this.configuredOriginOccupancy(address);
|
|
9655
|
+
if (!occupancy) return { target: { address, port }, refusals };
|
|
9656
|
+
refusals.push(occupancy);
|
|
9657
|
+
if (occupancy.kind === "silent") return { target: null, refusals };
|
|
9658
|
+
}
|
|
9659
|
+
return { target: null, refusals };
|
|
9660
|
+
}
|
|
9661
|
+
/**
|
|
9662
|
+
* Whether the last spawn attempt declined because an address this app may serve
|
|
9663
|
+
* on is held by something it does not own, so the app has nowhere of its own to
|
|
9664
|
+
* start a daemon.
|
|
9665
|
+
*
|
|
9666
|
+
* THIS IS THE ONE THING `index.ts` NEEDS FROM THIS CLASS, and it is the whole of
|
|
9667
|
+
* the incident it answers: a `false` from `start()` used to mean "quit", and this
|
|
9668
|
+
* class of false means the opposite - the app is fine, its address is taken, and
|
|
9669
|
+
* waiting is what recovers it.
|
|
9670
|
+
*/
|
|
9671
|
+
isStartBlockedByOccupiedAddress() {
|
|
9672
|
+
return this.spawnRefusals !== null;
|
|
9673
|
+
}
|
|
9160
9674
|
/**
|
|
9161
9675
|
* Publish what the spawn gate saw, in the vocabulary the copy already uses.
|
|
9162
9676
|
*
|
|
9163
|
-
*
|
|
9677
|
+
* Three outcomes reach here and each gets the state that names it: a Local
|
|
9164
9678
|
* Operator daemon this app may not drive is `unattachable` (state `wedged` - a
|
|
9165
9679
|
* server IS running and this app did not attach to it, no banner claiming it is
|
|
9166
|
-
* offline),
|
|
9680
|
+
* offline), an address that did not answer in time is `unanswered` -
|
|
9167
9681
|
* `degraded`, usable, no banner, and still counted, because a budget that
|
|
9168
|
-
* expired is not evidence of absence either way
|
|
9169
|
-
|
|
9170
|
-
|
|
9171
|
-
|
|
9172
|
-
|
|
9173
|
-
const detail =
|
|
9682
|
+
* expired is not evidence of absence either way - and a registry this app could
|
|
9683
|
+
* not read is `no-candidate`, which claims only that this app has no daemon of its
|
|
9684
|
+
* own (review round 1, R1-3).
|
|
9685
|
+
*/
|
|
9686
|
+
observeSpawnRefusal(refusals) {
|
|
9687
|
+
const detail = describeSpawnRefusal(refusals);
|
|
9688
|
+
const holder = refusals.find(
|
|
9689
|
+
(occupancy) => occupancy.kind === "daemon" || occupancy.kind === "record"
|
|
9690
|
+
);
|
|
9691
|
+
const silent = refusals.find(
|
|
9692
|
+
(occupancy) => occupancy.kind === "silent"
|
|
9693
|
+
);
|
|
9174
9694
|
this.daemonState.observe(
|
|
9175
|
-
|
|
9695
|
+
holder ? { kind: "unattachable", detail } : silent ? { kind: "unanswered", cause: silent.cause, detail } : { kind: "no-candidate", detail }
|
|
9176
9696
|
);
|
|
9177
9697
|
logger.info(`Not spawning a daemon: ${detail}`, LogFileType.BACKEND);
|
|
9178
9698
|
this.notifyStatus();
|
|
@@ -9466,7 +9986,7 @@ class BackendServiceManager {
|
|
|
9466
9986
|
if (this.process && this.process.exitCode === null && this.process.signalCode == null)
|
|
9467
9987
|
return;
|
|
9468
9988
|
if (await this.discoverAndAttach()) return;
|
|
9469
|
-
if (!this.managerMaySpawn || this.remoteConfigured || this.isExternalBackend
|
|
9989
|
+
if (!this.managerMaySpawn || this.remoteConfigured || this.isExternalBackend)
|
|
9470
9990
|
return;
|
|
9471
9991
|
await this.start({ quiet: true });
|
|
9472
9992
|
} finally {
|
|
@@ -30703,32 +31223,46 @@ electron.app.whenReady().then(async () => {
|
|
|
30703
31223
|
startAttempts++;
|
|
30704
31224
|
}
|
|
30705
31225
|
if (!backendStarted) {
|
|
30706
|
-
|
|
30707
|
-
|
|
30708
|
-
|
|
30709
|
-
|
|
30710
|
-
|
|
30711
|
-
|
|
30712
|
-
|
|
30713
|
-
|
|
30714
|
-
|
|
30715
|
-
|
|
31226
|
+
if (backendService.isStartBlockedByOccupiedAddress()) {
|
|
31227
|
+
logger.error(
|
|
31228
|
+
"Failed to start backend after installation: every address this app may serve on is held by something it does not own. Continuing without quitting; the status surface names the holder and the app keeps probing.",
|
|
31229
|
+
LogFileType.INSTALLER
|
|
31230
|
+
);
|
|
31231
|
+
} else {
|
|
31232
|
+
logger.error(
|
|
31233
|
+
"Failed to start backend after installation, quitting app",
|
|
31234
|
+
LogFileType.INSTALLER
|
|
31235
|
+
);
|
|
31236
|
+
reportBackendFailure(
|
|
31237
|
+
"Failed to start the Local Operator backend service after installation. Please restart the application.",
|
|
31238
|
+
LogFileType.INSTALLER
|
|
31239
|
+
);
|
|
31240
|
+
electron.app.quit();
|
|
31241
|
+
return;
|
|
31242
|
+
}
|
|
30716
31243
|
}
|
|
30717
31244
|
} else {
|
|
30718
31245
|
const backendStarted = await backendService.start({
|
|
30719
31246
|
reuseDiscovery: true
|
|
30720
31247
|
});
|
|
30721
31248
|
if (!backendStarted) {
|
|
30722
|
-
|
|
30723
|
-
|
|
30724
|
-
|
|
30725
|
-
|
|
30726
|
-
|
|
30727
|
-
|
|
30728
|
-
|
|
30729
|
-
|
|
30730
|
-
|
|
30731
|
-
|
|
31249
|
+
if (backendService.isStartBlockedByOccupiedAddress()) {
|
|
31250
|
+
logger.error(
|
|
31251
|
+
"Failed to start backend with existing installation: every address this app may serve on is held by something it does not own. Continuing without quitting; the status surface names the holder and the app keeps probing.",
|
|
31252
|
+
LogFileType.BACKEND
|
|
31253
|
+
);
|
|
31254
|
+
} else {
|
|
31255
|
+
logger.error(
|
|
31256
|
+
"Failed to start backend with existing installation, quitting app",
|
|
31257
|
+
LogFileType.BACKEND
|
|
31258
|
+
);
|
|
31259
|
+
reportBackendFailure(
|
|
31260
|
+
"Failed to start the Local Operator backend service. Please restart the application.",
|
|
31261
|
+
LogFileType.BACKEND
|
|
31262
|
+
);
|
|
31263
|
+
electron.app.quit();
|
|
31264
|
+
return;
|
|
31265
|
+
}
|
|
30732
31266
|
}
|
|
30733
31267
|
}
|
|
30734
31268
|
}
|