local-operator-ui 0.30.28 → 0.30.30
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 +627 -61
- package/out/renderer/assets/{_basePickBy-DVGL4dyS.js → _basePickBy-CPsYVQWB.js} +1 -1
- package/out/renderer/assets/{_baseUniq-BSXxR5nj.js → _baseUniq-DLGgf-MG.js} +1 -1
- package/out/renderer/assets/{agent-details-page-C1L4BYPg.js → agent-details-page-Dk5IUnd-.js} +1 -1
- package/out/renderer/assets/{agent-hub-page-KuHsq4as.js → agent-hub-page-B4vQ7ciV.js} +1 -1
- package/out/renderer/assets/{agents-page-CMyFoTdV.js → agents-page-C88U0W1_.js} +2 -2
- package/out/renderer/assets/{arc-DYtCn3b7.js → arc-BNeqY63k.js} +1 -1
- package/out/renderer/assets/{architectureDiagram-IEHRJDOE-DT3LiUFK.js → architectureDiagram-IEHRJDOE-CtUGmGSQ.js} +1 -1
- package/out/renderer/assets/{blockDiagram-JOT3LUYC-CeYb8oXA.js → blockDiagram-JOT3LUYC-C6moozjO.js} +1 -1
- package/out/renderer/assets/{browser-page-DH0PUHZ6.js → browser-page-BURCNtzT.js} +1 -1
- package/out/renderer/assets/{browser-webauthn-prompt-D3cdsaO1.js → browser-webauthn-prompt-8Zq_BRcf.js} +1 -1
- package/out/renderer/assets/{c4Diagram-VJAJSXHY-BfLZhnkq.js → c4Diagram-VJAJSXHY-5_-LJprq.js} +1 -1
- package/out/renderer/assets/channel-B4PSxtWF.js +1 -0
- package/out/renderer/assets/{chunk-4BMEZGHF-XdpXDuYe.js → chunk-4BMEZGHF-CA9BbUte.js} +1 -1
- package/out/renderer/assets/{chunk-A2AXSNBT-B55G-7NN.js → chunk-A2AXSNBT-B0_KaWEY.js} +1 -1
- package/out/renderer/assets/{chunk-AEK57VVT-Cvjg3Mip.js → chunk-AEK57VVT-DtEnX2KS.js} +1 -1
- package/out/renderer/assets/{chunk-D6G4REZN-B20z3IQR.js → chunk-D6G4REZN-C0ti2z-O.js} +1 -1
- package/out/renderer/assets/{chunk-RZ5BOZE2-CBx1LpGz.js → chunk-RZ5BOZE2-Z1oOd5-p.js} +1 -1
- package/out/renderer/assets/{chunk-XZIHB7SX-DQ3yYz4L.js → chunk-XZIHB7SX-C05DfsK9.js} +1 -1
- package/out/renderer/assets/{classDiagram-GIVACNV2-DhyBtCQr.js → classDiagram-GIVACNV2-nXu6mING.js} +1 -1
- package/out/renderer/assets/{classDiagram-v2-COTLJTTW-DhyBtCQr.js → classDiagram-v2-COTLJTTW-nXu6mING.js} +1 -1
- package/out/renderer/assets/clone-DtFNWeKR.js +1 -0
- package/out/renderer/assets/{compact-pagination-Ahq7SAdj.js → compact-pagination-CzQ02nEw.js} +1 -1
- package/out/renderer/assets/{dagre-OKDRZEBW-D51os3J2.js → dagre-OKDRZEBW-BAXMHrvQ.js} +1 -1
- package/out/renderer/assets/{diagram-SSKATNLV-DiqlVRyZ.js → diagram-SSKATNLV-C9P77nAF.js} +1 -1
- package/out/renderer/assets/{diagram-VNBRO52H-wyOk-7rJ.js → diagram-VNBRO52H-xFRgDIAb.js} +1 -1
- package/out/renderer/assets/{erDiagram-Q7BY3M3F-ClwBz7Vl.js → erDiagram-Q7BY3M3F-BO4H8v6o.js} +1 -1
- package/out/renderer/assets/{flowDiagram-4HSFHLVR-6vCU0UK_.js → flowDiagram-4HSFHLVR-66rwm5Ys.js} +1 -1
- package/out/renderer/assets/{ganttDiagram-APWFNJXF-oQ2A5p_K.js → ganttDiagram-APWFNJXF-BqqnKK3l.js} +1 -1
- package/out/renderer/assets/{gitGraphDiagram-7IBYFJ6S-Jmz0NydS.js → gitGraphDiagram-7IBYFJ6S-BCX5W9Fv.js} +1 -1
- package/out/renderer/assets/{graph-CZlT6mkf.js → graph-MPlQJvAd.js} +1 -1
- package/out/renderer/assets/{index-BBIALF90.js → index-BlA78hq8.js} +1 -1
- package/out/renderer/assets/{index-CMKwoZ6K.js → index-SOwWJvc_.js} +405 -405
- package/out/renderer/assets/{index-NY8u3QY3.js → index-omFl7ggM.js} +1 -1
- package/out/renderer/assets/{infoDiagram-PH2N3AL5-CWdY5eB5.js → infoDiagram-PH2N3AL5-D5BTO0mH.js} +1 -1
- package/out/renderer/assets/{journeyDiagram-U35MCT3I-CZN3Z4Do.js → journeyDiagram-U35MCT3I-_t8-Br70.js} +1 -1
- package/out/renderer/assets/{kanban-definition-NDS4AKOZ-O78rt8N8.js → kanban-definition-NDS4AKOZ-BSX0xk8p.js} +1 -1
- package/out/renderer/assets/{layout-BCu2X7f9.js → layout-BEmQhA9p.js} +1 -1
- package/out/renderer/assets/{legacy-agents-page-V_ZM9450.js → legacy-agents-page-tWvKwmxt.js} +1 -1
- package/out/renderer/assets/{mermaid.core-CQECvCmy.js → mermaid.core-DHWyYSZ-.js} +5 -5
- package/out/renderer/assets/{mindmap-definition-ALO5MXBD-BYiKzv5D.js → mindmap-definition-ALO5MXBD-lcm9xUYF.js} +1 -1
- package/out/renderer/assets/{parseISO-DUtfRwUe.js → parseISO-BRYRtYXt.js} +1 -1
- package/out/renderer/assets/{pieDiagram-IB7DONF6-BK-dabrh.js → pieDiagram-IB7DONF6-B7h5vMH0.js} +1 -1
- package/out/renderer/assets/{quadrantDiagram-7GDLP6J5-DwNrRMPZ.js → quadrantDiagram-7GDLP6J5-8MSjEGwK.js} +1 -1
- package/out/renderer/assets/{radar-MK3ICKWK-Dwa-5BxU.js → radar-MK3ICKWK-rPCWWY0t.js} +1 -1
- package/out/renderer/assets/{radient-auth-buttons-DPj8zZ0H.js → radient-auth-buttons-CPaSSPI7.js} +1 -1
- package/out/renderer/assets/{requirementDiagram-KVF5MWMF-CAvi9bJA.js → requirementDiagram-KVF5MWMF-qLpc_srh.js} +1 -1
- package/out/renderer/assets/{sankeyDiagram-QLVOVGJD-BIJv0fDy.js → sankeyDiagram-QLVOVGJD-xL32Ga0-.js} +1 -1
- package/out/renderer/assets/{schedules-page-yik4dl4q.js → schedules-page-CyGDJ-Z-.js} +1 -1
- package/out/renderer/assets/{sequenceDiagram-X6HHIX6F-so-gEOtr.js → sequenceDiagram-X6HHIX6F-B-KNPCAR.js} +1 -1
- package/out/renderer/assets/{settings-page-C85MzeE6.js → settings-page-CuxTNnIH.js} +66 -66
- package/out/renderer/assets/{stateDiagram-DGXRK772-CI5xRVbz.js → stateDiagram-DGXRK772-BjCqsm5E.js} +1 -1
- package/out/renderer/assets/{stateDiagram-v2-YXO3MK2T-CGpOff_s.js → stateDiagram-v2-YXO3MK2T-D_SOQqRZ.js} +1 -1
- package/out/renderer/assets/{timeline-definition-BDJGKUSR-Cr3Lo1sS.js → timeline-definition-BDJGKUSR-ByfHlvXq.js} +1 -1
- package/out/renderer/assets/{use-agent-like-mutation-CWE6shLn.js → use-agent-like-mutation-B2YnyhlD.js} +1 -1
- package/out/renderer/assets/{xychartDiagram-VJFVF3MP-CUwvyhxh.js → xychartDiagram-VJFVF3MP-DiWQbreg.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
|
}
|
|
@@ -223,6 +226,9 @@ const publicationDocument = zod.z.object({
|
|
|
223
226
|
categories: zod.z.array(zod.z.string()),
|
|
224
227
|
tags: zod.z.array(zod.z.string())
|
|
225
228
|
}).partial().strict();
|
|
229
|
+
const catalogueScopeKind = zod.z.enum(["team", "agent"]);
|
|
230
|
+
const catalogueScopeName = zod.z.string().min(1).max(64);
|
|
231
|
+
const catalogueCursor = zod.z.string().min(1).max(256);
|
|
226
232
|
const desktopRequestSchema = zod.z.discriminatedUnion("op", [
|
|
227
233
|
zod.z.object({ op: zod.z.literal("capabilities") }).strict(),
|
|
228
234
|
zod.z.object({ op: zod.z.literal("profiles.list") }).strict(),
|
|
@@ -272,7 +278,31 @@ const desktopRequestSchema = zod.z.discriminatedUnion("op", [
|
|
|
272
278
|
* questions a list that hides them cannot answer: the open conversation's own
|
|
273
279
|
* archived state, and an unarchive control on a row found by search.
|
|
274
280
|
*/
|
|
275
|
-
include_archived: zod.z.boolean().optional()
|
|
281
|
+
include_archived: zod.z.boolean().optional(),
|
|
282
|
+
/*
|
|
283
|
+
* The four parameters that make the catalogue PAGEABLE, and the switch that
|
|
284
|
+
* makes the daemon count it.
|
|
285
|
+
*
|
|
286
|
+
* ALL FOUR ARE OPTIONAL AND DEFAULTED, which is the whole compatibility
|
|
287
|
+
* promise: a request that sends none of them is byte-identical to the one
|
|
288
|
+
* this app sent before they existed, and an older daemon is therefore fully
|
|
289
|
+
* supported. They are gated on the `session_catalogue_page` capability rather
|
|
290
|
+
* than on a `session_catalogue` version bump, for the reason that map's own
|
|
291
|
+
* register states (an EXISTING surface must keep working against a backend
|
|
292
|
+
* that lacks the new one): FastAPI silently ignores unknown query parameters,
|
|
293
|
+
* so an un-gated client asking for `scope_kind=team&scope_name=lopdev` would
|
|
294
|
+
* receive the UNSCOPED page and draw other teams' rows under that team, and an
|
|
295
|
+
* un-gated `cursor` would receive page one again and duplicate it. The client
|
|
296
|
+
* has to be able to ask whether the daemon understands these, and the
|
|
297
|
+
* capability map is how this codebase asks.
|
|
298
|
+
*
|
|
299
|
+
* They travel as ONE contract revision rather than three: counts without the
|
|
300
|
+
* scope could not be rendered consistently with that scope's paged rows.
|
|
301
|
+
*/
|
|
302
|
+
scope_kind: catalogueScopeKind.optional(),
|
|
303
|
+
scope_name: catalogueScopeName.optional(),
|
|
304
|
+
cursor: catalogueCursor.optional(),
|
|
305
|
+
with_counts: zod.z.boolean().optional()
|
|
276
306
|
}).strict(),
|
|
277
307
|
zod.z.object({
|
|
278
308
|
// Content search over the store: name, id, exact conversation body, and
|
|
@@ -1323,14 +1353,19 @@ function desktopEndpoint(request) {
|
|
|
1323
1353
|
method: "PATCH",
|
|
1324
1354
|
body: { request_id: request.requestId, ...request.fields }
|
|
1325
1355
|
};
|
|
1326
|
-
case "sessions.list":
|
|
1356
|
+
case "sessions.list": {
|
|
1357
|
+
const params = new URLSearchParams();
|
|
1358
|
+
params.set("limit", String(request.limit ?? 100));
|
|
1359
|
+
if (request.include_archived) params.set("include_archived", "true");
|
|
1360
|
+
if (request.scope_kind) params.set("scope_kind", request.scope_kind);
|
|
1361
|
+
if (request.scope_name) params.set("scope_name", request.scope_name);
|
|
1362
|
+
if (request.cursor) params.set("cursor", request.cursor);
|
|
1363
|
+
if (request.with_counts) params.set("with_counts", "true");
|
|
1327
1364
|
return {
|
|
1328
|
-
|
|
1329
|
-
// states: the pre-flag request is what an older backend must keep
|
|
1330
|
-
// seeing, and `false` is the route's default anyway.
|
|
1331
|
-
path: `/v1/desktop/sessions?limit=${request.limit ?? 100}${request.include_archived ? "&include_archived=true" : ""}`,
|
|
1365
|
+
path: `/v1/desktop/sessions?${params}`,
|
|
1332
1366
|
method: "GET"
|
|
1333
1367
|
};
|
|
1368
|
+
}
|
|
1334
1369
|
case "sessions.search": {
|
|
1335
1370
|
const query = new URLSearchParams({
|
|
1336
1371
|
q: request.q,
|
|
@@ -2056,6 +2091,65 @@ function readServeRecords(dir) {
|
|
|
2056
2091
|
}
|
|
2057
2092
|
return { files, problem: null };
|
|
2058
2093
|
}
|
|
2094
|
+
function recordsHolding(files, address, now) {
|
|
2095
|
+
if (!address) return [];
|
|
2096
|
+
const wanted = normaliseAddress(address) ?? address;
|
|
2097
|
+
const records = [];
|
|
2098
|
+
for (const entry of files) {
|
|
2099
|
+
if (!entry.record) continue;
|
|
2100
|
+
if (recordAddress(entry.record) !== wanted) continue;
|
|
2101
|
+
if (recordPidLiveness(entry.record, now) === "dead") continue;
|
|
2102
|
+
records.push(entry.record);
|
|
2103
|
+
}
|
|
2104
|
+
return records;
|
|
2105
|
+
}
|
|
2106
|
+
function addressHoldsLiveRecord(address, options = {}) {
|
|
2107
|
+
const { unreadable, records } = addressHolders(address, options);
|
|
2108
|
+
return unreadable || records.length > 0;
|
|
2109
|
+
}
|
|
2110
|
+
function anyRecordHoldsSomething(files, now) {
|
|
2111
|
+
return files.some(
|
|
2112
|
+
(entry) => !entry.record || recordPidLiveness(entry.record, now) !== "dead"
|
|
2113
|
+
);
|
|
2114
|
+
}
|
|
2115
|
+
function addressHolders(address, options = {}) {
|
|
2116
|
+
if (!address) return { unreadable: false, records: [] };
|
|
2117
|
+
const { files, problem } = readServeRecords(
|
|
2118
|
+
serveRunDir(options.env ?? process.env)
|
|
2119
|
+
);
|
|
2120
|
+
return {
|
|
2121
|
+
unreadable: problem === "unreadable",
|
|
2122
|
+
records: recordsHolding(files, address, (options.now ?? Date.now)())
|
|
2123
|
+
};
|
|
2124
|
+
}
|
|
2125
|
+
function listenerPidsOn(port) {
|
|
2126
|
+
if (!Number.isInteger(port) || port <= 0 || port > 65535) return [];
|
|
2127
|
+
try {
|
|
2128
|
+
const out = node_child_process.execFileSync(
|
|
2129
|
+
"lsof",
|
|
2130
|
+
["-nP", `-iTCP:${port}`, "-sTCP:LISTEN", "-t"],
|
|
2131
|
+
{ encoding: "utf8", timeout: 2e3 }
|
|
2132
|
+
);
|
|
2133
|
+
const pids = /* @__PURE__ */ new Set();
|
|
2134
|
+
for (const line of out.split("\n")) {
|
|
2135
|
+
const pid = Number.parseInt(line.trim(), 10);
|
|
2136
|
+
if (Number.isInteger(pid) && pid > 0) pids.add(pid);
|
|
2137
|
+
}
|
|
2138
|
+
return [...pids];
|
|
2139
|
+
} catch {
|
|
2140
|
+
return [];
|
|
2141
|
+
}
|
|
2142
|
+
}
|
|
2143
|
+
function readRecordForPid(pid, env = process.env) {
|
|
2144
|
+
if (!Number.isInteger(pid) || pid <= 0) return null;
|
|
2145
|
+
const file = path$1.join(serveRunDir(env), `${pid}.json`);
|
|
2146
|
+
try {
|
|
2147
|
+
const entry = parseRecord(JSON.parse(fs.readFileSync(file, "utf8")), file);
|
|
2148
|
+
return entry.record && entry.record.pid === pid ? entry.record : null;
|
|
2149
|
+
} catch {
|
|
2150
|
+
return null;
|
|
2151
|
+
}
|
|
2152
|
+
}
|
|
2059
2153
|
function pidLiveness(pid, options = {}) {
|
|
2060
2154
|
try {
|
|
2061
2155
|
process.kill(pid, 0);
|
|
@@ -2096,8 +2190,18 @@ function classifyRecord(record, now, liveness = recordPidLiveness(record, now))
|
|
|
2096
2190
|
return age > HEARTBEAT_TIMEOUT_MS ? "wedged" : "live";
|
|
2097
2191
|
}
|
|
2098
2192
|
function recordAddress(record) {
|
|
2099
|
-
|
|
2100
|
-
|
|
2193
|
+
return addressOf("http:", canonicalHost(record.host), record.port);
|
|
2194
|
+
}
|
|
2195
|
+
function canonicalHost(host) {
|
|
2196
|
+
const bare = host.startsWith("[") && host.endsWith("]") ? host.slice(1, -1) : host;
|
|
2197
|
+
const unqualified = bare.endsWith(".") ? bare.slice(0, -1) : bare;
|
|
2198
|
+
const lower = unqualified.toLowerCase();
|
|
2199
|
+
if (lower === "localhost") return "127.0.0.1";
|
|
2200
|
+
if (lower === "::1" || lower === "0:0:0:0:0:0:0:1") return "::1";
|
|
2201
|
+
return bare;
|
|
2202
|
+
}
|
|
2203
|
+
function addressOf(scheme, host, port) {
|
|
2204
|
+
return `${scheme}//${host.includes(":") ? `[${host}]` : host}:${port}`;
|
|
2101
2205
|
}
|
|
2102
2206
|
function classifyUnreachable(error) {
|
|
2103
2207
|
const name = error?.name;
|
|
@@ -2320,12 +2424,24 @@ async function discoverDaemons(options = {}) {
|
|
|
2320
2424
|
wedged,
|
|
2321
2425
|
noRecordsAtAll: files.length === 0,
|
|
2322
2426
|
// A dead PID is positive evidence; timeout, malformed JSON or a stopped
|
|
2323
|
-
// heartbeat is not. Do not spawn over a
|
|
2324
|
-
//
|
|
2427
|
+
// heartbeat is not. Do not spawn over an address a record says is held. The
|
|
2428
|
+
// liveness question is asked the way `registry.py::scan` asks it, so a
|
|
2325
2429
|
// zombie record is dead here too and cannot block spawning forever.
|
|
2326
|
-
|
|
2327
|
-
|
|
2328
|
-
|
|
2430
|
+
//
|
|
2431
|
+
// SCOPED TO THE CONFIGURED ADDRESS (2026-09-23). It used to be scoped to the
|
|
2432
|
+
// ROOT - any record anywhere in this config root with a non-dead pid - which
|
|
2433
|
+
// forbade the spawn onto the address this app was actually configured for
|
|
2434
|
+
// because something else in the root had a live record. `addressHoldsLiveRecord`
|
|
2435
|
+
// is the same predicate for the addresses this app may also spawn on, so the
|
|
2436
|
+
// per-address gate in `backend-service.ts` and this verdict cannot disagree.
|
|
2437
|
+
// A caller that names NO address gets the old root-wide answer instead
|
|
2438
|
+
// (`anyRecordHoldsSomething`): there is then no address this verdict can call
|
|
2439
|
+
// free, and answering "free" on no evidence is the direction this module never
|
|
2440
|
+
// takes. The unparseable record lives in that arm for the same reason - it
|
|
2441
|
+
// cannot be attributed to an address at all - and what it used to stand for, a
|
|
2442
|
+
// daemon holding a port and not answering, is now asked directly of the port by
|
|
2443
|
+
// the spawn gate's own occupancy probe.
|
|
2444
|
+
blocksSpawn: problem === "unreadable" || (configuredAddress === null ? anyRecordHoldsSomething(files, now()) : recordsHolding(files, configuredAddress, now()).length > 0)
|
|
2329
2445
|
};
|
|
2330
2446
|
for (const rejection of rejected) {
|
|
2331
2447
|
log(
|
|
@@ -2341,9 +2457,13 @@ function normaliseAddress(url) {
|
|
|
2341
2457
|
if (!url) return null;
|
|
2342
2458
|
try {
|
|
2343
2459
|
const parsed = new URL(url);
|
|
2344
|
-
|
|
2460
|
+
if (!parsed.hostname) return null;
|
|
2345
2461
|
const port = parsed.port || (parsed.protocol === "https:" ? "443" : "80");
|
|
2346
|
-
return
|
|
2462
|
+
return addressOf(
|
|
2463
|
+
parsed.protocol,
|
|
2464
|
+
canonicalHost(parsed.hostname),
|
|
2465
|
+
Number(port)
|
|
2466
|
+
);
|
|
2347
2467
|
} catch {
|
|
2348
2468
|
return null;
|
|
2349
2469
|
}
|
|
@@ -6290,6 +6410,17 @@ class DaemonStateMachine {
|
|
|
6290
6410
|
getUrl() {
|
|
6291
6411
|
return this.identity?.url ?? null;
|
|
6292
6412
|
}
|
|
6413
|
+
/**
|
|
6414
|
+
* The connection's own snapshot.
|
|
6415
|
+
*
|
|
6416
|
+
* IT RETURNS EVERY FIELD EXCEPT `addressSubstitution`, and the Omission is the
|
|
6417
|
+
* point rather than a type trick (agent round 2, R2-1): that field is DERIVED
|
|
6418
|
+
* from the address this app is actually on, so it is assembled by the manager
|
|
6419
|
+
* (`BackendServiceManager.getStatusSnapshot`) which is the only object that holds
|
|
6420
|
+
* both the configured address and the connection. A machine that could write it
|
|
6421
|
+
* here would be a second producer, which is exactly how the field came to claim a
|
|
6422
|
+
* fallback the app had already left.
|
|
6423
|
+
*/
|
|
6293
6424
|
snapshot() {
|
|
6294
6425
|
return {
|
|
6295
6426
|
state: this.state,
|
|
@@ -7302,6 +7433,110 @@ function readinessPollDelayMs(elapsedMs) {
|
|
|
7302
7433
|
return READINESS_POLL_INTERVAL_MS;
|
|
7303
7434
|
}
|
|
7304
7435
|
const DESKTOP_TOKEN_FILENAME = "desktop-token";
|
|
7436
|
+
function portOf(address) {
|
|
7437
|
+
try {
|
|
7438
|
+
const parsed = Number.parseInt(new URL(address).port, 10);
|
|
7439
|
+
return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
|
|
7440
|
+
} catch {
|
|
7441
|
+
return null;
|
|
7442
|
+
}
|
|
7443
|
+
}
|
|
7444
|
+
function installName(prefix) {
|
|
7445
|
+
return prefix ? path$1.basename(prefix) : "";
|
|
7446
|
+
}
|
|
7447
|
+
function describeStartedAt(startedAtMs) {
|
|
7448
|
+
const started = new Date(startedAtMs);
|
|
7449
|
+
if (started.toDateString() === (/* @__PURE__ */ new Date()).toDateString()) {
|
|
7450
|
+
return `started ${started.toLocaleTimeString(void 0, {
|
|
7451
|
+
hour: "numeric",
|
|
7452
|
+
minute: "2-digit"
|
|
7453
|
+
})}`;
|
|
7454
|
+
}
|
|
7455
|
+
return `started ${started.toLocaleDateString(void 0, {
|
|
7456
|
+
year: "numeric",
|
|
7457
|
+
month: "short",
|
|
7458
|
+
day: "numeric"
|
|
7459
|
+
})}`;
|
|
7460
|
+
}
|
|
7461
|
+
function describeOccupant(occupant) {
|
|
7462
|
+
const facts = [];
|
|
7463
|
+
if (occupant.pid !== null) {
|
|
7464
|
+
facts.push(
|
|
7465
|
+
occupant.pidSource === "listener" ? `pid ${occupant.pid} read off the listening socket` : `pid ${occupant.pid}`
|
|
7466
|
+
);
|
|
7467
|
+
}
|
|
7468
|
+
const install = occupant.installKind || installName(occupant.prefix);
|
|
7469
|
+
if (install) facts.push(install);
|
|
7470
|
+
if (occupant.version) facts.push(`v${occupant.version}`);
|
|
7471
|
+
if (occupant.startedAtMs !== null) {
|
|
7472
|
+
facts.push(describeStartedAt(occupant.startedAtMs));
|
|
7473
|
+
}
|
|
7474
|
+
if (occupant.desktopReadStatus !== null) {
|
|
7475
|
+
facts.push(
|
|
7476
|
+
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}`
|
|
7477
|
+
);
|
|
7478
|
+
}
|
|
7479
|
+
return facts.length > 0 ? ` (${facts.join(", ")})` : "";
|
|
7480
|
+
}
|
|
7481
|
+
const HOLDER_CLASS = {
|
|
7482
|
+
daemon: {
|
|
7483
|
+
one: "is running a Local Operator daemon this app has no key for",
|
|
7484
|
+
many: "are running Local Operator daemons this app has no key for"
|
|
7485
|
+
},
|
|
7486
|
+
record: {
|
|
7487
|
+
one: "is listed as still running in this app's own records",
|
|
7488
|
+
many: "are listed as still running in this app's own records"
|
|
7489
|
+
}
|
|
7490
|
+
};
|
|
7491
|
+
function listAddresses(items) {
|
|
7492
|
+
if (items.length === 0) return "";
|
|
7493
|
+
if (items.length === 1) return items[0];
|
|
7494
|
+
return `${items.slice(0, -1).join(", ")} and ${items[items.length - 1]}`;
|
|
7495
|
+
}
|
|
7496
|
+
function describeHolder(occupancy) {
|
|
7497
|
+
return `${occupancy.occupant.address}${describeOccupant(occupancy.occupant)}`;
|
|
7498
|
+
}
|
|
7499
|
+
function describeHolders(refusals) {
|
|
7500
|
+
const fragments = [];
|
|
7501
|
+
for (const kind of ["daemon", "record"]) {
|
|
7502
|
+
const group = refusals.filter((occupancy) => occupancy.kind === kind);
|
|
7503
|
+
if (group.length === 0) continue;
|
|
7504
|
+
fragments.push(
|
|
7505
|
+
`${listAddresses(group.map(describeHolder))} ${HOLDER_CLASS[kind][group.length === 1 ? "one" : "many"]}`
|
|
7506
|
+
);
|
|
7507
|
+
}
|
|
7508
|
+
for (const occupancy of refusals) {
|
|
7509
|
+
if (occupancy.kind !== "silent") continue;
|
|
7510
|
+
fragments.push(
|
|
7511
|
+
`${describeHolder(occupancy)} did not answer this app's probe with anything it could use (${occupancy.detail})`
|
|
7512
|
+
);
|
|
7513
|
+
}
|
|
7514
|
+
if (refusals.some((occupancy) => occupancy.kind === "unreadable")) {
|
|
7515
|
+
fragments.push(
|
|
7516
|
+
"this app's own records could not be read, so no address it may serve on could be called free"
|
|
7517
|
+
);
|
|
7518
|
+
}
|
|
7519
|
+
return fragments.join("; ");
|
|
7520
|
+
}
|
|
7521
|
+
const holderCount = (refusals) => refusals.length;
|
|
7522
|
+
function reclaimClause(refusals) {
|
|
7523
|
+
const pids = refusals.map((occupancy) => occupancy.occupant.pid).filter((pid) => pid !== null);
|
|
7524
|
+
if (pids.length === 0) return "";
|
|
7525
|
+
const single = holderCount(refusals) === 1 && pids.length === 1;
|
|
7526
|
+
const command = single ? `lop services reclaim ${pids[0]}` : "lop services reclaim <pid>";
|
|
7527
|
+
const direct = single ? "it from the install that owns it" : "them from the installs that own them";
|
|
7528
|
+
return ` Stop ${direct} with ${command} (lop services status lists what is running).`;
|
|
7529
|
+
}
|
|
7530
|
+
function describeSpawnRefusal(refusals) {
|
|
7531
|
+
const named = refusals.filter((occupancy) => occupancy.kind !== "unreadable");
|
|
7532
|
+
const stopped = named.length === 0 ? "Nothing was started" : named.length === 1 ? "Nothing was started over it" : "Nothing was started over them";
|
|
7533
|
+
return `${describeHolders(refusals)}. ${stopped}.${reclaimClause(refusals)} It keeps probing for a server it can open.`;
|
|
7534
|
+
}
|
|
7535
|
+
const FALLBACK_SPAWN_URL = "http://127.0.0.1:8080";
|
|
7536
|
+
const NO_SUBSTITUTION_REASON = Object.freeze({
|
|
7537
|
+
holder: null,
|
|
7538
|
+
reclaim: null
|
|
7539
|
+
});
|
|
7305
7540
|
const execPromise = node_util.promisify(node_child_process.exec);
|
|
7306
7541
|
const ENV_VAR_REGEX = /^([^=]+)=(.*)$/;
|
|
7307
7542
|
function classifyDesktopAnswer(status2) {
|
|
@@ -7329,7 +7564,50 @@ class BackendServiceManager {
|
|
|
7329
7564
|
startupMode = "NOT_STARTED";
|
|
7330
7565
|
port;
|
|
7331
7566
|
backendUrl;
|
|
7567
|
+
/**
|
|
7568
|
+
* The address this app is CONFIGURED to serve on, which never rotates.
|
|
7569
|
+
*
|
|
7570
|
+
* `backendUrl` is where the app is TALKING - a daemon discovery adopted, or the
|
|
7571
|
+
* fallback address after a fallback spawn - so a spawn gate keyed on it would
|
|
7572
|
+
* ask "may I start a daemon here" about whatever the app happens to be attached
|
|
7573
|
+
* to, which is somebody else's daemon. The config address is a separate fact and
|
|
7574
|
+
* it is the one the gate needs.
|
|
7575
|
+
*/
|
|
7576
|
+
configuredUrl;
|
|
7577
|
+
/**
|
|
7578
|
+
* The addresses this app may spawn a managed daemon on after the configured one,
|
|
7579
|
+
* best first (`FALLBACK_SPAWN_URL` by default).
|
|
7580
|
+
*
|
|
7581
|
+
* A field rather than a bare constant so a rig can exercise the fallback on a
|
|
7582
|
+
* port the kernel picked, which is the same reason `DiscoverOptions.fetchImpl`
|
|
7583
|
+
* is injectable: proving this path must not require binding a fixed port on a
|
|
7584
|
+
* machine somebody else is using.
|
|
7585
|
+
*/
|
|
7586
|
+
fallbackSpawnUrls;
|
|
7587
|
+
/**
|
|
7588
|
+
* What stopped the last spawn attempt, when what stopped it was an address this
|
|
7589
|
+
* app may spawn on being held by something IT DOES NOT OWN. Null when nothing
|
|
7590
|
+
* did.
|
|
7591
|
+
*
|
|
7592
|
+
* Read by `index.ts` through `isStartBlockedByOccupiedAddress()`: a false return
|
|
7593
|
+
* from `start()` is the one place the app used to quit, and the whole point of
|
|
7594
|
+
* this field is that this class of failure is not a reason to take the app down.
|
|
7595
|
+
*/
|
|
7596
|
+
spawnRefusals = null;
|
|
7332
7597
|
remoteConfigured = false;
|
|
7598
|
+
/**
|
|
7599
|
+
* The last address this app served on that was NOT the one it is configured for,
|
|
7600
|
+
* and the reason the gate gave for moving there.
|
|
7601
|
+
*
|
|
7602
|
+
* NEITHER IS THE CLAIM - both are history. The claim itself is derived on every
|
|
7603
|
+
* snapshot from the address the connection is on (`addressSubstitutionFor`), so a
|
|
7604
|
+
* reason recorded here can only reach a surface while the app is genuinely on
|
|
7605
|
+
* that other address (agent round 2, R2-1/R2-2). That is what makes it safe to
|
|
7606
|
+
* write the reason at the moment the gate makes its decision, which is the only
|
|
7607
|
+
* moment the holder is observable at all.
|
|
7608
|
+
*/
|
|
7609
|
+
servedElsewhere = null;
|
|
7610
|
+
substitutionReason = NO_SUBSTITUTION_REASON;
|
|
7333
7611
|
/** A failed probe or unreadable record is not evidence that spawning is safe. */
|
|
7334
7612
|
discoveryBlocksSpawn = false;
|
|
7335
7613
|
/**
|
|
@@ -7540,7 +7818,98 @@ class BackendServiceManager {
|
|
|
7540
7818
|
* "I am not allowed" into "it is down".
|
|
7541
7819
|
*/
|
|
7542
7820
|
getStatusSnapshot() {
|
|
7543
|
-
|
|
7821
|
+
const snapshot2 = this.daemonState.snapshot();
|
|
7822
|
+
return {
|
|
7823
|
+
...snapshot2,
|
|
7824
|
+
addressSubstitution: this.addressSubstitutionFor(snapshot2)
|
|
7825
|
+
};
|
|
7826
|
+
}
|
|
7827
|
+
/**
|
|
7828
|
+
* Where this app is serving, relative to the address it is configured for.
|
|
7829
|
+
*
|
|
7830
|
+
* THE INVARIANT (agent round 2, R2-1): a snapshot's address claim is a FUNCTION
|
|
7831
|
+
* OF THE ADDRESS THE APP IS ACTUALLY USING, never of the last decision a gate
|
|
7832
|
+
* made. The first version of this field was written once by the spawn gate, and
|
|
7833
|
+
* that produced two wrong answers in opposite directions:
|
|
7834
|
+
*
|
|
7835
|
+
* - a launch that ATTACHES to a daemon discovered on another address (the
|
|
7836
|
+
* second launch of the incident - the fallback daemon is still there and the
|
|
7837
|
+
* persisted credential still opens it) records nothing, so the app was
|
|
7838
|
+
* silently on another address, which is the invisibility D1 exists to remove;
|
|
7839
|
+
* - a substitution recorded earlier was never retracted: once recovery attached
|
|
7840
|
+
* the app back to the configured address the record still said `serving:
|
|
7841
|
+
* <fallback>`, beside a snapshot whose own `url` was the configured address,
|
|
7842
|
+
* and its reclaim advice named a pid that may by then be the daemon the
|
|
7843
|
+
* operator is using.
|
|
7844
|
+
*
|
|
7845
|
+
* It also answers agent round 2's R2-2 for free: the claim is derived from
|
|
7846
|
+
* `isRunning` and the attached URL, so a start that never landed - a readiness
|
|
7847
|
+
* timeout, a throw, a stop - reports nothing at all, where a record written at
|
|
7848
|
+
* intent time announced a return over a dead backend.
|
|
7849
|
+
*
|
|
7850
|
+
* `holder`/`reclaim` are the one fact the derivation cannot recover, because the
|
|
7851
|
+
* gate is the only witness to what held the address; they are kept beside the
|
|
7852
|
+
* landing (`substitutionReasonFor`, which the landing publishes) and reach a surface
|
|
7853
|
+
* only through an arm
|
|
7854
|
+
* that is true. Null holder/reclaim is the honest state for a launch that adopted
|
|
7855
|
+
* somebody else's daemon: it never asked the configured address anything.
|
|
7856
|
+
*/
|
|
7857
|
+
addressSubstitutionFor(snapshot2) {
|
|
7858
|
+
const configured = normaliseAddress(this.configuredUrl);
|
|
7859
|
+
if (!configured || !snapshot2.url || !isServerReachable(snapshot2.state)) {
|
|
7860
|
+
return null;
|
|
7861
|
+
}
|
|
7862
|
+
const serving = normaliseAddress(snapshot2.url);
|
|
7863
|
+
if (!serving) return null;
|
|
7864
|
+
if (serving !== configured) {
|
|
7865
|
+
return {
|
|
7866
|
+
kind: "substituted",
|
|
7867
|
+
configured,
|
|
7868
|
+
serving,
|
|
7869
|
+
...this.substitutionReason
|
|
7870
|
+
};
|
|
7871
|
+
}
|
|
7872
|
+
return this.servedElsewhere ? { kind: "returned", configured, serving: this.servedElsewhere } : null;
|
|
7873
|
+
}
|
|
7874
|
+
/**
|
|
7875
|
+
* Adopt a validated daemon, and record WHERE the app landed and WHY it landed there.
|
|
7876
|
+
*
|
|
7877
|
+
* Every landing in this class goes through here - the spawn's registration and
|
|
7878
|
+
* both attach paths - which is what makes the claim a property of the app's
|
|
7879
|
+
* address rather than of one code path (agent round 2, R2-1). `servedElsewhere`
|
|
7880
|
+
* survives a landing on the configured address on purpose: it is what a return
|
|
7881
|
+
* has to name.
|
|
7882
|
+
*
|
|
7883
|
+
* THE REASON TRAVELS WITH THE LANDING rather than being written beside it (agent
|
|
7884
|
+
* round 3, R3-1). It used to be cleared here and re-recorded by the caller AFTER
|
|
7885
|
+
* this call returned, which meant the snapshot this landing pushed carried
|
|
7886
|
+
* `holder: null` for a fallback spawn - the ADOPTED arm's sentence about a daemon
|
|
7887
|
+
* the app had just started, and with the act withheld - until the next read. Now
|
|
7888
|
+
* the spawn path hands the reason in and the landing publishes it, so there is one
|
|
7889
|
+
* push per landing and it is the true one. The default is the empty pair, which is
|
|
7890
|
+
* what an attach path means: this app observed no holder because it never asked.
|
|
7891
|
+
*/
|
|
7892
|
+
attachDaemon(identity, options, reason = NO_SUBSTITUTION_REASON) {
|
|
7893
|
+
this.daemonState.attach(identity, options);
|
|
7894
|
+
const configured = normaliseAddress(this.configuredUrl);
|
|
7895
|
+
const landed = normaliseAddress(identity.url);
|
|
7896
|
+
if (landed && configured && landed !== configured) {
|
|
7897
|
+
this.servedElsewhere = landed;
|
|
7898
|
+
}
|
|
7899
|
+
this.substitutionReason = reason;
|
|
7900
|
+
}
|
|
7901
|
+
/**
|
|
7902
|
+
* The reason a fallback was taken, composed where the gate's refusals are still in
|
|
7903
|
+
* hand - and HANDED to the landing rather than written beside it (agent round 2,
|
|
7904
|
+
* R2-2 for the timing, agent round 3, R3-1 for the hand-off). A start that never
|
|
7905
|
+
* lands never carries this anywhere, and the claim is not there to render it
|
|
7906
|
+
* anyway.
|
|
7907
|
+
*/
|
|
7908
|
+
substitutionReasonFor(refusals) {
|
|
7909
|
+
return refusals.length > 0 ? {
|
|
7910
|
+
holder: describeHolders(refusals),
|
|
7911
|
+
reclaim: reclaimClause(refusals).trim() || null
|
|
7912
|
+
} : NO_SUBSTITUTION_REASON;
|
|
7544
7913
|
}
|
|
7545
7914
|
getStreamRelay() {
|
|
7546
7915
|
if (!this.streamRelay || this.streamRelayUrl !== this.backendUrl || this.streamRelayToken !== this.desktopToken) {
|
|
@@ -7720,6 +8089,7 @@ class BackendServiceManager {
|
|
|
7720
8089
|
*/
|
|
7721
8090
|
constructor(options = {}) {
|
|
7722
8091
|
this.userShellPath = options.userShellPath;
|
|
8092
|
+
this.fallbackSpawnUrls = options.fallbackSpawnUrls ?? [FALLBACK_SPAWN_URL];
|
|
7723
8093
|
try {
|
|
7724
8094
|
const apiUrl = new URL(backendConfig.VITE_LOCAL_OPERATOR_API_URL);
|
|
7725
8095
|
this.port = Number.parseInt(apiUrl.port, 10) || 1111;
|
|
@@ -7740,6 +8110,7 @@ class BackendServiceManager {
|
|
|
7740
8110
|
error
|
|
7741
8111
|
);
|
|
7742
8112
|
}
|
|
8113
|
+
this.configuredUrl = this.backendUrl;
|
|
7743
8114
|
this.venvPath = managedVenvPath({
|
|
7744
8115
|
platform: process.platform,
|
|
7745
8116
|
home: electron.app.getPath("home"),
|
|
@@ -8420,7 +8791,7 @@ class BackendServiceManager {
|
|
|
8420
8791
|
`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
8792
|
LogFileType.BACKEND
|
|
8422
8793
|
);
|
|
8423
|
-
this.
|
|
8794
|
+
this.attachDaemon(
|
|
8424
8795
|
{
|
|
8425
8796
|
url: candidate.address,
|
|
8426
8797
|
instanceId: candidate.identity.instanceId,
|
|
@@ -8511,7 +8882,7 @@ class BackendServiceManager {
|
|
|
8511
8882
|
this.attachedRecord = null;
|
|
8512
8883
|
this.isExternalBackend = true;
|
|
8513
8884
|
this.startupMode = "EXISTING_SERVER";
|
|
8514
|
-
this.
|
|
8885
|
+
this.attachDaemon(
|
|
8515
8886
|
{
|
|
8516
8887
|
url: this.backendUrl,
|
|
8517
8888
|
instanceId: "",
|
|
@@ -8639,8 +9010,8 @@ class BackendServiceManager {
|
|
|
8639
9010
|
return true;
|
|
8640
9011
|
}
|
|
8641
9012
|
if (epoch !== this.startEpoch || this.isAppClosing) return false;
|
|
8642
|
-
if (!this.managerMaySpawn || this.remoteConfigured
|
|
8643
|
-
const noSpawnReason = this.managerMaySpawn ?
|
|
9013
|
+
if (!this.managerMaySpawn || this.remoteConfigured) {
|
|
9014
|
+
const noSpawnReason = this.managerMaySpawn ? `the configured target (${this.backendUrl}) is remote` : "VITE_DISABLE_BACKEND_MANAGER=true";
|
|
8644
9015
|
logger.info(
|
|
8645
9016
|
`No daemon discovered, and this app is configured not to spawn one (${noSpawnReason}).`,
|
|
8646
9017
|
LogFileType.BACKEND
|
|
@@ -8649,12 +9020,21 @@ class BackendServiceManager {
|
|
|
8649
9020
|
this.startHealthCheck();
|
|
8650
9021
|
return false;
|
|
8651
9022
|
}
|
|
8652
|
-
const
|
|
8653
|
-
|
|
8654
|
-
|
|
9023
|
+
const { target: target2, refusals } = await this.resolveSpawnTarget();
|
|
9024
|
+
this.spawnRefusals = target2 ? null : refusals;
|
|
9025
|
+
if (!target2) {
|
|
9026
|
+
this.observeSpawnRefusal(refusals);
|
|
8655
9027
|
this.startHealthCheck();
|
|
8656
9028
|
return false;
|
|
8657
9029
|
}
|
|
9030
|
+
this.port = target2.port;
|
|
9031
|
+
this.backendUrl = target2.address;
|
|
9032
|
+
if (refusals.length > 0) {
|
|
9033
|
+
logger.warn(
|
|
9034
|
+
`Starting a managed daemon on ${target2.address} instead: ${describeHolders(refusals)}.`,
|
|
9035
|
+
LogFileType.BACKEND
|
|
9036
|
+
);
|
|
9037
|
+
}
|
|
8658
9038
|
this.desktopToken = this.mintDesktopToken();
|
|
8659
9039
|
let captured = null;
|
|
8660
9040
|
try {
|
|
@@ -8723,7 +9103,8 @@ class BackendServiceManager {
|
|
|
8723
9103
|
break;
|
|
8724
9104
|
if (healthy) {
|
|
8725
9105
|
this.isRunning = true;
|
|
8726
|
-
|
|
9106
|
+
const reason = this.substitutionReasonFor(refusals);
|
|
9107
|
+
await this.registerOwnedDaemon(child, reason);
|
|
8727
9108
|
this.startHealthCheck();
|
|
8728
9109
|
this.notifyBackendReady();
|
|
8729
9110
|
return true;
|
|
@@ -8788,7 +9169,7 @@ class BackendServiceManager {
|
|
|
8788
9169
|
* second arm is the case of an install predating the record format, which is
|
|
8789
9170
|
* reachable on a fixed port exactly as the deprecated adoption path allows.
|
|
8790
9171
|
*/
|
|
8791
|
-
async registerOwnedDaemon(child) {
|
|
9172
|
+
async registerOwnedDaemon(child, reason) {
|
|
8792
9173
|
const identity = await this.ownedDaemonIdentity(child);
|
|
8793
9174
|
if (!identity) {
|
|
8794
9175
|
logger.warn(
|
|
@@ -8797,7 +9178,7 @@ class BackendServiceManager {
|
|
|
8797
9178
|
);
|
|
8798
9179
|
return;
|
|
8799
9180
|
}
|
|
8800
|
-
this.
|
|
9181
|
+
this.attachDaemon(identity, { owned: true }, reason);
|
|
8801
9182
|
this.daemonState.setPairing(DAEMON_PAIRED);
|
|
8802
9183
|
this.notifyStatus();
|
|
8803
9184
|
logger.info(
|
|
@@ -9106,16 +9487,15 @@ class BackendServiceManager {
|
|
|
9106
9487
|
* F-2), and it returns a `silent` occupancy below rather than a licence to
|
|
9107
9488
|
* spawn.
|
|
9108
9489
|
*/
|
|
9109
|
-
async configuredOriginOccupancy() {
|
|
9110
|
-
const probe = await probeUnidentified(
|
|
9490
|
+
async configuredOriginOccupancy(address) {
|
|
9491
|
+
const probe = await probeUnidentified(address, {
|
|
9111
9492
|
timeoutMs: PROBE_TIMEOUT_MS
|
|
9112
9493
|
});
|
|
9113
9494
|
switch (probe.reason) {
|
|
9114
9495
|
case "identity-mismatch":
|
|
9115
9496
|
return {
|
|
9116
9497
|
kind: "daemon",
|
|
9117
|
-
|
|
9118
|
-
version: probe.identity?.version ?? "",
|
|
9498
|
+
occupant: this.occupantOf(address, probe.identity ?? null),
|
|
9119
9499
|
detail: probe.detail
|
|
9120
9500
|
};
|
|
9121
9501
|
/*
|
|
@@ -9135,11 +9515,12 @@ class BackendServiceManager {
|
|
|
9135
9515
|
return {
|
|
9136
9516
|
kind: "silent",
|
|
9137
9517
|
cause: "other",
|
|
9518
|
+
occupant: this.occupantOf(address, null),
|
|
9138
9519
|
detail: probe.detail
|
|
9139
9520
|
};
|
|
9140
9521
|
case "not-a-daemon":
|
|
9141
9522
|
logger.info(
|
|
9142
|
-
`${
|
|
9523
|
+
`${address}${HEALTH_PATH$1} answered and is not a Local Operator daemon (${probe.detail}); starting a daemon anyway, as this app always has.`,
|
|
9143
9524
|
LogFileType.BACKEND
|
|
9144
9525
|
);
|
|
9145
9526
|
return null;
|
|
@@ -9147,32 +9528,203 @@ class BackendServiceManager {
|
|
|
9147
9528
|
return probe.cause === "refused" ? null : {
|
|
9148
9529
|
kind: "silent",
|
|
9149
9530
|
cause: probe.cause ?? "other",
|
|
9531
|
+
occupant: this.occupantOf(address, null),
|
|
9150
9532
|
detail: probe.detail
|
|
9151
9533
|
};
|
|
9152
9534
|
default:
|
|
9153
9535
|
return {
|
|
9154
9536
|
kind: "silent",
|
|
9155
9537
|
cause: "other",
|
|
9538
|
+
occupant: this.occupantOf(address, null),
|
|
9156
9539
|
detail: probe.detail
|
|
9157
9540
|
};
|
|
9158
9541
|
}
|
|
9159
9542
|
}
|
|
9543
|
+
/**
|
|
9544
|
+
* One address's holder, from the facts already in hand.
|
|
9545
|
+
*
|
|
9546
|
+
* WHERE EACH FACT COMES FROM, because the copy has to be able to say it. The
|
|
9547
|
+
* occupant's own `/health` is asked first and is the only source that proves who
|
|
9548
|
+
* holds the address; when it did not answer with an identity - a timeout, a
|
|
9549
|
+
* non-200, a body with no `instance_id` - the LISTENING SOCKET is asked instead
|
|
9550
|
+
* (`listenerPidsOn`), and `pidSource` carries which of the two produced the pid
|
|
9551
|
+
* so the sentence reports a kernel fact as a kernel fact.
|
|
9552
|
+
*
|
|
9553
|
+
* The record lookup goes the other way round: it is keyed by the pid, so it adds
|
|
9554
|
+
* `started_at` and the published install to whichever source named the holder.
|
|
9555
|
+
* A holder that ALSO has a record in this app's own root is usually one of this
|
|
9556
|
+
* app's own daemons from an earlier run, and that is worth saying out loud.
|
|
9557
|
+
*/
|
|
9558
|
+
occupantOf(address, identity) {
|
|
9559
|
+
let pid = identity && Number.isInteger(identity.pid) && identity.pid > 0 ? identity.pid : null;
|
|
9560
|
+
let pidSource = pid === null ? null : "answer";
|
|
9561
|
+
if (pid === null) {
|
|
9562
|
+
const port = portOf(address);
|
|
9563
|
+
const [found] = port === null ? [] : listenerPidsOn(port);
|
|
9564
|
+
if (found !== void 0) {
|
|
9565
|
+
pid = found;
|
|
9566
|
+
pidSource = "listener";
|
|
9567
|
+
}
|
|
9568
|
+
}
|
|
9569
|
+
const record = pid === null ? null : readRecordForPid(pid);
|
|
9570
|
+
return {
|
|
9571
|
+
address,
|
|
9572
|
+
pid,
|
|
9573
|
+
pidSource,
|
|
9574
|
+
version: identity?.version || record?.version || "",
|
|
9575
|
+
prefix: identity?.prefix || record?.prefix || "",
|
|
9576
|
+
installKind: identity?.installKind || record?.install_kind || "",
|
|
9577
|
+
startedAtMs: record ? record.started_at * 1e3 : null,
|
|
9578
|
+
// Probe-side occupancy never carries this: `configuredOriginOccupancy` is
|
|
9579
|
+
// asked about an address the attach path did not just read, and inventing
|
|
9580
|
+
// a status here would be the same class of guess the record arm avoids.
|
|
9581
|
+
desktopReadStatus: null
|
|
9582
|
+
};
|
|
9583
|
+
}
|
|
9584
|
+
/**
|
|
9585
|
+
* The addresses this app may spawn a managed daemon on, best first.
|
|
9586
|
+
*
|
|
9587
|
+
* The configured address leads, so a fallback spawn does not make the app forget
|
|
9588
|
+
* where it was told to serve: `backendUrl` rotates to wherever the app ended up,
|
|
9589
|
+
* while this list is recomputed from the configuration on every attempt. The rest
|
|
9590
|
+
* are the fallback budget, de-duplicated by `normaliseAddress` so a configuration
|
|
9591
|
+
* that already names 8080 does not probe one address twice - and the de-dup reads
|
|
9592
|
+
* through `canonicalHost`, so a configuration naming `localhost` is not probed a
|
|
9593
|
+
* second time as `127.0.0.1` (review round 1, R1-6: this comment asserted that
|
|
9594
|
+
* folding before `normaliseAddress` did it, which is how the assertion and the
|
|
9595
|
+
* comparison could disagree without either looking wrong).
|
|
9596
|
+
*/
|
|
9597
|
+
spawnAddresses() {
|
|
9598
|
+
const addresses = [this.configuredUrl, ...this.fallbackSpawnUrls];
|
|
9599
|
+
const seen = /* @__PURE__ */ new Set();
|
|
9600
|
+
const allowed = [];
|
|
9601
|
+
for (const address of addresses) {
|
|
9602
|
+
const normalised = normaliseAddress(address);
|
|
9603
|
+
if (!normalised || seen.has(normalised)) continue;
|
|
9604
|
+
if (portOf(normalised) === null) continue;
|
|
9605
|
+
seen.add(normalised);
|
|
9606
|
+
allowed.push(normalised);
|
|
9607
|
+
}
|
|
9608
|
+
return allowed;
|
|
9609
|
+
}
|
|
9610
|
+
/**
|
|
9611
|
+
* Record where this attempt's daemon is going, relative to the address this app
|
|
9612
|
+
* is configured for (design round 1, D1).
|
|
9613
|
+
*
|
|
9614
|
+
* WHY THE SNAPSHOT AND NOT ONLY THE LOG LINE. Measured on this change: serving on
|
|
9615
|
+
* the fallback address was pixel-for-pixel invisible - the "attached on 8080" and
|
|
9616
|
+
* "attached on 1111" frames of the whole surface hashed identically - and the
|
|
9617
|
+
* operator's own question in that state had no in-product answer while they
|
|
9618
|
+
* worked. What is recorded here is what the band renders.
|
|
9619
|
+
*
|
|
9620
|
+
/**
|
|
9621
|
+
* The first address this attempt may actually start a daemon on, and what the
|
|
9622
|
+
* gate found on every address that refused.
|
|
9623
|
+
*
|
|
9624
|
+
* TWO GATES PER ADDRESS, and each answers a different question. The record gate
|
|
9625
|
+
* (`addressHoldsLiveRecord`) is "does this app's own registry say a process holds
|
|
9626
|
+
* this address": its subject is a pid, and it needs no network. The occupancy
|
|
9627
|
+
* probe is "would a child of mine be able to bind this address", asked of the
|
|
9628
|
+
* address itself - and it exists precisely because the two came apart on the
|
|
9629
|
+
* operator's machine, where discovery had no record to work with, the app spawned
|
|
9630
|
+
* onto a port a daemon was already serving, and the child died on `[Errno 48]`
|
|
9631
|
+
* every ~10 s (see the comment at the call site).
|
|
9632
|
+
*
|
|
9633
|
+
* ORDER MATTERS and it is the record gate first: a live record for the address
|
|
9634
|
+
* means the address is held by something whose pid we can already name, and
|
|
9635
|
+
* probing it would only add an answer to a question already answered.
|
|
9636
|
+
*/
|
|
9637
|
+
async resolveSpawnTarget() {
|
|
9638
|
+
const refusals = [];
|
|
9639
|
+
for (const address of this.spawnAddresses()) {
|
|
9640
|
+
const port = portOf(address);
|
|
9641
|
+
if (port === null) continue;
|
|
9642
|
+
if (addressHoldsLiveRecord(address)) {
|
|
9643
|
+
const { unreadable, records } = addressHolders(address);
|
|
9644
|
+
if (unreadable) {
|
|
9645
|
+
refusals.push({
|
|
9646
|
+
kind: "unreadable",
|
|
9647
|
+
occupant: {
|
|
9648
|
+
address,
|
|
9649
|
+
pid: null,
|
|
9650
|
+
pidSource: null,
|
|
9651
|
+
version: "",
|
|
9652
|
+
prefix: "",
|
|
9653
|
+
installKind: "",
|
|
9654
|
+
startedAtMs: null,
|
|
9655
|
+
desktopReadStatus: null
|
|
9656
|
+
},
|
|
9657
|
+
detail: "this app's own records could not be read, so no address it may serve on could be called free"
|
|
9658
|
+
});
|
|
9659
|
+
continue;
|
|
9660
|
+
}
|
|
9661
|
+
const record = records[0] ?? null;
|
|
9662
|
+
const observed = this.answeredButUnusable?.address === address ? this.answeredButUnusable.status : null;
|
|
9663
|
+
refusals.push({
|
|
9664
|
+
kind: "record",
|
|
9665
|
+
occupant: {
|
|
9666
|
+
address,
|
|
9667
|
+
pid: record?.pid ?? null,
|
|
9668
|
+
// The record is this app's own file about its own child, so the pid
|
|
9669
|
+
// is this app's own answer rather than a socket's.
|
|
9670
|
+
pidSource: record ? "answer" : null,
|
|
9671
|
+
version: record?.version ?? "",
|
|
9672
|
+
prefix: record?.prefix ?? "",
|
|
9673
|
+
installKind: record?.install_kind ?? "",
|
|
9674
|
+
startedAtMs: record ? record.started_at * 1e3 : null,
|
|
9675
|
+
desktopReadStatus: observed
|
|
9676
|
+
},
|
|
9677
|
+
/*
|
|
9678
|
+
* Not user-facing: the record arm is rendered by `HOLDER_CLASS.record`, which
|
|
9679
|
+
* words the fact for a reader. This is the diagnostic beside it, for a log or
|
|
9680
|
+
* a debugger, so it keeps the module's own nouns.
|
|
9681
|
+
*/
|
|
9682
|
+
detail: `a serve record names ${address} with a pid that is not proven dead`
|
|
9683
|
+
});
|
|
9684
|
+
continue;
|
|
9685
|
+
}
|
|
9686
|
+
const occupancy = await this.configuredOriginOccupancy(address);
|
|
9687
|
+
if (!occupancy) return { target: { address, port }, refusals };
|
|
9688
|
+
refusals.push(occupancy);
|
|
9689
|
+
if (occupancy.kind === "silent") return { target: null, refusals };
|
|
9690
|
+
}
|
|
9691
|
+
return { target: null, refusals };
|
|
9692
|
+
}
|
|
9693
|
+
/**
|
|
9694
|
+
* Whether the last spawn attempt declined because an address this app may serve
|
|
9695
|
+
* on is held by something it does not own, so the app has nowhere of its own to
|
|
9696
|
+
* start a daemon.
|
|
9697
|
+
*
|
|
9698
|
+
* THIS IS THE ONE THING `index.ts` NEEDS FROM THIS CLASS, and it is the whole of
|
|
9699
|
+
* the incident it answers: a `false` from `start()` used to mean "quit", and this
|
|
9700
|
+
* class of false means the opposite - the app is fine, its address is taken, and
|
|
9701
|
+
* waiting is what recovers it.
|
|
9702
|
+
*/
|
|
9703
|
+
isStartBlockedByOccupiedAddress() {
|
|
9704
|
+
return this.spawnRefusals !== null;
|
|
9705
|
+
}
|
|
9160
9706
|
/**
|
|
9161
9707
|
* Publish what the spawn gate saw, in the vocabulary the copy already uses.
|
|
9162
9708
|
*
|
|
9163
|
-
*
|
|
9709
|
+
* Three outcomes reach here and each gets the state that names it: a Local
|
|
9164
9710
|
* Operator daemon this app may not drive is `unattachable` (state `wedged` - a
|
|
9165
9711
|
* server IS running and this app did not attach to it, no banner claiming it is
|
|
9166
|
-
* offline),
|
|
9712
|
+
* offline), an address that did not answer in time is `unanswered` -
|
|
9167
9713
|
* `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 =
|
|
9714
|
+
* expired is not evidence of absence either way - and a registry this app could
|
|
9715
|
+
* not read is `no-candidate`, which claims only that this app has no daemon of its
|
|
9716
|
+
* own (review round 1, R1-3).
|
|
9717
|
+
*/
|
|
9718
|
+
observeSpawnRefusal(refusals) {
|
|
9719
|
+
const detail = describeSpawnRefusal(refusals);
|
|
9720
|
+
const holder = refusals.find(
|
|
9721
|
+
(occupancy) => occupancy.kind === "daemon" || occupancy.kind === "record"
|
|
9722
|
+
);
|
|
9723
|
+
const silent = refusals.find(
|
|
9724
|
+
(occupancy) => occupancy.kind === "silent"
|
|
9725
|
+
);
|
|
9174
9726
|
this.daemonState.observe(
|
|
9175
|
-
|
|
9727
|
+
holder ? { kind: "unattachable", detail } : silent ? { kind: "unanswered", cause: silent.cause, detail } : { kind: "no-candidate", detail }
|
|
9176
9728
|
);
|
|
9177
9729
|
logger.info(`Not spawning a daemon: ${detail}`, LogFileType.BACKEND);
|
|
9178
9730
|
this.notifyStatus();
|
|
@@ -9466,7 +10018,7 @@ class BackendServiceManager {
|
|
|
9466
10018
|
if (this.process && this.process.exitCode === null && this.process.signalCode == null)
|
|
9467
10019
|
return;
|
|
9468
10020
|
if (await this.discoverAndAttach()) return;
|
|
9469
|
-
if (!this.managerMaySpawn || this.remoteConfigured || this.isExternalBackend
|
|
10021
|
+
if (!this.managerMaySpawn || this.remoteConfigured || this.isExternalBackend)
|
|
9470
10022
|
return;
|
|
9471
10023
|
await this.start({ quiet: true });
|
|
9472
10024
|
} finally {
|
|
@@ -30703,32 +31255,46 @@ electron.app.whenReady().then(async () => {
|
|
|
30703
31255
|
startAttempts++;
|
|
30704
31256
|
}
|
|
30705
31257
|
if (!backendStarted) {
|
|
30706
|
-
|
|
30707
|
-
|
|
30708
|
-
|
|
30709
|
-
|
|
30710
|
-
|
|
30711
|
-
|
|
30712
|
-
|
|
30713
|
-
|
|
30714
|
-
|
|
30715
|
-
|
|
31258
|
+
if (backendService.isStartBlockedByOccupiedAddress()) {
|
|
31259
|
+
logger.error(
|
|
31260
|
+
"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.",
|
|
31261
|
+
LogFileType.INSTALLER
|
|
31262
|
+
);
|
|
31263
|
+
} else {
|
|
31264
|
+
logger.error(
|
|
31265
|
+
"Failed to start backend after installation, quitting app",
|
|
31266
|
+
LogFileType.INSTALLER
|
|
31267
|
+
);
|
|
31268
|
+
reportBackendFailure(
|
|
31269
|
+
"Failed to start the Local Operator backend service after installation. Please restart the application.",
|
|
31270
|
+
LogFileType.INSTALLER
|
|
31271
|
+
);
|
|
31272
|
+
electron.app.quit();
|
|
31273
|
+
return;
|
|
31274
|
+
}
|
|
30716
31275
|
}
|
|
30717
31276
|
} else {
|
|
30718
31277
|
const backendStarted = await backendService.start({
|
|
30719
31278
|
reuseDiscovery: true
|
|
30720
31279
|
});
|
|
30721
31280
|
if (!backendStarted) {
|
|
30722
|
-
|
|
30723
|
-
|
|
30724
|
-
|
|
30725
|
-
|
|
30726
|
-
|
|
30727
|
-
|
|
30728
|
-
|
|
30729
|
-
|
|
30730
|
-
|
|
30731
|
-
|
|
31281
|
+
if (backendService.isStartBlockedByOccupiedAddress()) {
|
|
31282
|
+
logger.error(
|
|
31283
|
+
"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.",
|
|
31284
|
+
LogFileType.BACKEND
|
|
31285
|
+
);
|
|
31286
|
+
} else {
|
|
31287
|
+
logger.error(
|
|
31288
|
+
"Failed to start backend with existing installation, quitting app",
|
|
31289
|
+
LogFileType.BACKEND
|
|
31290
|
+
);
|
|
31291
|
+
reportBackendFailure(
|
|
31292
|
+
"Failed to start the Local Operator backend service. Please restart the application.",
|
|
31293
|
+
LogFileType.BACKEND
|
|
31294
|
+
);
|
|
31295
|
+
electron.app.quit();
|
|
31296
|
+
return;
|
|
31297
|
+
}
|
|
30732
31298
|
}
|
|
30733
31299
|
}
|
|
30734
31300
|
}
|