local-operator-ui 0.28.2 → 0.28.4
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 +620 -63
- package/out/renderer/assets/{_basePickBy-BiVlVvaz.js → _basePickBy-m0UwIwI-.js} +1 -1
- package/out/renderer/assets/{_baseUniq-B7hmLjHp.js → _baseUniq-B1e5WxRp.js} +1 -1
- package/out/renderer/assets/agent-details-page-DT2XjUkD.js +6 -0
- package/out/renderer/assets/agent-hub-page-CDkOhuwp.js +6 -0
- package/out/renderer/assets/{agents-page-DpmiwHFn.js → agents-page-CdW9L7GP.js} +2 -2
- package/out/renderer/assets/{arc-zQfKMj24.js → arc-CvNo9IfF.js} +1 -1
- package/out/renderer/assets/{architectureDiagram-IEHRJDOE-B7ZATeGZ.js → architectureDiagram-IEHRJDOE-b6gyJWBp.js} +1 -1
- package/out/renderer/assets/{blockDiagram-JOT3LUYC-CHlXcfDj.js → blockDiagram-JOT3LUYC-BagjA5Xe.js} +1 -1
- package/out/renderer/assets/browser-page-D7BKWCO0.js +1 -0
- package/out/renderer/assets/{c4Diagram-VJAJSXHY-Cqc7SCB2.js → c4Diagram-VJAJSXHY-DD_BZI1i.js} +1 -1
- package/out/renderer/assets/channel-CF2CxemR.js +1 -0
- package/out/renderer/assets/{chunk-4BMEZGHF-Bsl_7NtA.js → chunk-4BMEZGHF-CBD8EzOC.js} +1 -1
- package/out/renderer/assets/{chunk-A2AXSNBT-ggoGKGXQ.js → chunk-A2AXSNBT-CveWCVjO.js} +1 -1
- package/out/renderer/assets/{chunk-AEK57VVT-DqWWmU8P.js → chunk-AEK57VVT-C0vMuZFa.js} +1 -1
- package/out/renderer/assets/{chunk-D6G4REZN-BoMbjS6y.js → chunk-D6G4REZN-DzjTeFHj.js} +1 -1
- package/out/renderer/assets/{chunk-RZ5BOZE2-is_C2hBC.js → chunk-RZ5BOZE2-ZGCVflK1.js} +1 -1
- package/out/renderer/assets/{chunk-XZIHB7SX-ld86pApa.js → chunk-XZIHB7SX-DoQvlXVO.js} +1 -1
- package/out/renderer/assets/classDiagram-GIVACNV2-0fYfsmc-.js +1 -0
- package/out/renderer/assets/classDiagram-v2-COTLJTTW-0fYfsmc-.js +1 -0
- package/out/renderer/assets/clone-DAEsvOHv.js +1 -0
- package/out/renderer/assets/{compact-pagination-BSC2TmbH.js → compact-pagination-CJsjPK0e.js} +1 -1
- package/out/renderer/assets/{dagre-OKDRZEBW-32aJrB_O.js → dagre-OKDRZEBW-Kt7tHHXg.js} +1 -1
- package/out/renderer/assets/{diagram-SSKATNLV-Amr2xP2t.js → diagram-SSKATNLV-C95pxXOh.js} +1 -1
- package/out/renderer/assets/{diagram-VNBRO52H-Bc_-ILS5.js → diagram-VNBRO52H-DzNoiDve.js} +1 -1
- package/out/renderer/assets/{erDiagram-Q7BY3M3F-ZwSCjJ6e.js → erDiagram-Q7BY3M3F-D6pLsfhY.js} +1 -1
- package/out/renderer/assets/{flowDiagram-4HSFHLVR-Dql7kx-Z.js → flowDiagram-4HSFHLVR-CsYwACng.js} +1 -1
- package/out/renderer/assets/{ganttDiagram-APWFNJXF-DSWWoa_C.js → ganttDiagram-APWFNJXF-CnIGzOXa.js} +1 -1
- package/out/renderer/assets/{gitGraphDiagram-7IBYFJ6S-cbUhz4PY.js → gitGraphDiagram-7IBYFJ6S-DsCw2EgC.js} +1 -1
- package/out/renderer/assets/{graph-CojCeg8k.js → graph-D4YQS541.js} +1 -1
- package/out/renderer/assets/{icon-BJ1diQ13.js → icon-Bt4fnn3F.js} +1 -1
- package/out/renderer/assets/icon-DhYiAgZq.css +1 -0
- package/out/renderer/assets/{index-DgY41JzN.js → index-BNyd28pr.js} +1 -1
- package/out/renderer/assets/{index-DP42nWkP.js → index-CME8QOxA.js} +1 -1
- package/out/renderer/assets/{index-YYBGshw-.js → index-CSkqUKM-.js} +384 -374
- package/out/renderer/assets/{infoDiagram-PH2N3AL5-DCWE8TqR.js → infoDiagram-PH2N3AL5-DObO_6iZ.js} +1 -1
- package/out/renderer/assets/{installer-BH6UzMkw.js → installer-BWJdvQyC.js} +3 -3
- package/out/renderer/assets/{journeyDiagram-U35MCT3I-BVApHQpT.js → journeyDiagram-U35MCT3I-CtE5xgnZ.js} +1 -1
- package/out/renderer/assets/{kanban-definition-NDS4AKOZ-BHlQbvEk.js → kanban-definition-NDS4AKOZ-g5xy4MmB.js} +1 -1
- package/out/renderer/assets/{layout-C68weM2q.js → layout-ClrAgc3u.js} +1 -1
- package/out/renderer/assets/{legacy-agents-page-DySZFeGz.js → legacy-agents-page-BtejMok0.js} +3 -3
- package/out/renderer/assets/{mermaid.core-mZpI-6tM.js → mermaid.core-DvlHbnIM.js} +5 -5
- package/out/renderer/assets/{mindmap-definition-ALO5MXBD-CUBn2plb.js → mindmap-definition-ALO5MXBD-CwOcxWvn.js} +1 -1
- package/out/renderer/assets/{page-header-BkCnUAVS.js → page-header-DX9YUSyo.js} +1 -1
- package/out/renderer/assets/{parseISO-BUcHeIn4.js → parseISO-C3zww-5R.js} +1 -1
- package/out/renderer/assets/{pieDiagram-IB7DONF6-D9khOoXJ.js → pieDiagram-IB7DONF6-D0QyueG-.js} +1 -1
- package/out/renderer/assets/{quadrantDiagram-7GDLP6J5-Ckc_HLn6.js → quadrantDiagram-7GDLP6J5-c33oG3Jl.js} +1 -1
- package/out/renderer/assets/{radar-MK3ICKWK-E_AG5HZR.js → radar-MK3ICKWK-O1mRjAtb.js} +1 -1
- package/out/renderer/assets/{radient-auth-buttons-DWMBn27v.js → radient-auth-buttons-C6SOaDr-.js} +2 -2
- package/out/renderer/assets/{requirementDiagram-KVF5MWMF-D4z3bPKG.js → requirementDiagram-KVF5MWMF-BfCMVyv1.js} +1 -1
- package/out/renderer/assets/{sankeyDiagram-QLVOVGJD-CdqXkAPm.js → sankeyDiagram-QLVOVGJD-DLPDxWoI.js} +1 -1
- package/out/renderer/assets/{schedules-page-DKdkQ-bz.js → schedules-page-tTbriOVH.js} +1 -1
- package/out/renderer/assets/{sequenceDiagram-X6HHIX6F-cRXMF1u2.js → sequenceDiagram-X6HHIX6F-cScFWwXx.js} +1 -1
- package/out/renderer/assets/{settings-page-DxOCG1h9.js → settings-page-CUMzyo_U.js} +8 -8
- package/out/renderer/assets/{stateDiagram-DGXRK772-rwTz9XiH.js → stateDiagram-DGXRK772-DXtPvR30.js} +1 -1
- package/out/renderer/assets/stateDiagram-v2-YXO3MK2T-DU6SrBbV.js +1 -0
- package/out/renderer/assets/{timeline-definition-BDJGKUSR-DjpmacOZ.js → timeline-definition-BDJGKUSR-BNiecZOr.js} +1 -1
- package/out/renderer/assets/use-agent-like-mutation-Dp1XJuzk.js +1 -0
- package/out/renderer/assets/{xychartDiagram-VJFVF3MP-C-PscNet.js → xychartDiagram-VJFVF3MP-1FizMplM.js} +1 -1
- package/out/renderer/index.html +3 -3
- package/out/renderer/installer.html +3 -3
- package/package.json +2 -2
- package/out/renderer/assets/agent-details-page-Bo1iXiw2.js +0 -6
- package/out/renderer/assets/agent-hub-page-DpYsf3xz.js +0 -11
- package/out/renderer/assets/browser-page-XN0o_4Fc.js +0 -1
- package/out/renderer/assets/channel-DZNoINil.js +0 -1
- package/out/renderer/assets/classDiagram-GIVACNV2-CWYh2jD8.js +0 -1
- package/out/renderer/assets/classDiagram-v2-COTLJTTW-CWYh2jD8.js +0 -1
- package/out/renderer/assets/clone-3puJuEer.js +0 -1
- package/out/renderer/assets/icon-B9Dq7y-Q.css +0 -1
- package/out/renderer/assets/stateDiagram-v2-YXO3MK2T-DK-O9XV3.js +0 -1
- package/out/renderer/assets/use-agent-like-mutation-BgxlHPcf.js +0 -1
package/out/main/index.js
CHANGED
|
@@ -393,6 +393,38 @@ const desktopRequestSchema = zod.z.discriminatedUnion("op", [
|
|
|
393
393
|
sessionId,
|
|
394
394
|
completionToken: zod.z.string().uuid()
|
|
395
395
|
}).strict(),
|
|
396
|
+
/*
|
|
397
|
+
* Bulk acknowledgement: mark many completions read in ONE user gesture
|
|
398
|
+
* (`POST /v1/desktop/attention/seen`).
|
|
399
|
+
*
|
|
400
|
+
* Its OWN op rather than a loop over `sessions.seen`, for two reasons that are
|
|
401
|
+
* both about what the receipt MEANS. The backend writes the whole batch in one
|
|
402
|
+
* `BEGIN IMMEDIATE`, so an observer sees the pre-batch or the post-batch receipt
|
|
403
|
+
* set and never a prefix; N round trips would expose a partial batch to every
|
|
404
|
+
* poller, and a failure half way would leave the user unable to tell which
|
|
405
|
+
* marks went. And the items are TOKEN-bound on purpose: the batch names exactly
|
|
406
|
+
* the completions the client has RENDERED, so a completion published between
|
|
407
|
+
* the render and the click is not in it and stays unread. A watermark sweep
|
|
408
|
+
* ("acknowledge everything now") would clear exactly that result instead, which
|
|
409
|
+
* is the hazard the per-session receipt exists to prevent — so this op never
|
|
410
|
+
* carries a time, a count or a "all" flag, only the pairs the client observed.
|
|
411
|
+
*
|
|
412
|
+
* `items` is 1..500: 500 is the catalogue's own maximum page, so every row the
|
|
413
|
+
* sidebar can hold is sendable in one call and `markAllRead` never has to
|
|
414
|
+
* chunk (chunking would break "one gesture, one request").
|
|
415
|
+
*/
|
|
416
|
+
zod.z.object({
|
|
417
|
+
op: zod.z.literal("attention.seen"),
|
|
418
|
+
items: zod.z.array(
|
|
419
|
+
zod.z.object({
|
|
420
|
+
sessionId,
|
|
421
|
+
// A REAL uuid, like `sessions.seen`: a token that is not one cannot
|
|
422
|
+
// name a completion, so it is refused here rather than round-tripped
|
|
423
|
+
// to answer `unknown` for an item the client should not have sent.
|
|
424
|
+
completionToken: zod.z.string().uuid()
|
|
425
|
+
}).strict()
|
|
426
|
+
).min(1).max(500)
|
|
427
|
+
}).strict(),
|
|
396
428
|
// Cross-surface delivery claim, NOT a read receipt. `claim_delivery`
|
|
397
429
|
// serialises the observers that can see one completion (a TUI, this app) so
|
|
398
430
|
// exactly one of them toasts it. It deliberately never advances the read
|
|
@@ -909,6 +941,13 @@ const desktopRequestSchema = zod.z.discriminatedUnion("op", [
|
|
|
909
941
|
"agents.favourited",
|
|
910
942
|
"agents.favourite_count",
|
|
911
943
|
"agents.download_count",
|
|
944
|
+
/*
|
|
945
|
+
* The viewer's own like/favourite state for a whole page of agents. The
|
|
946
|
+
* closed vocabulary has to name it here as well as in
|
|
947
|
+
* `shared/api/radient/proxy.ts`, because this schema is what validates
|
|
948
|
+
* the request the renderer actually sends.
|
|
949
|
+
*/
|
|
950
|
+
"agents.statuses",
|
|
912
951
|
"comments.list",
|
|
913
952
|
"comments.create",
|
|
914
953
|
"comments.update",
|
|
@@ -992,6 +1031,7 @@ function desktopRequestDeadlineMs(op) {
|
|
|
992
1031
|
return LONG_READ_OPS.has(op) ? DESKTOP_LONG_READ_DEADLINE_MS : DESKTOP_CONTROL_DEADLINE_MS;
|
|
993
1032
|
}
|
|
994
1033
|
const DESKTOP_DEADLINE_EXCEEDED_CODE = "deadline_exceeded";
|
|
1034
|
+
const DESKTOP_FOREGROUND_REQUIRED_MESSAGE = "View these completions in the foreground before marking them read.";
|
|
995
1035
|
const READ_ONLY_OPS = /* @__PURE__ */ new Set([
|
|
996
1036
|
"capabilities",
|
|
997
1037
|
"accounts.list",
|
|
@@ -1234,6 +1274,25 @@ function desktopEndpoint(request) {
|
|
|
1234
1274
|
method: "POST",
|
|
1235
1275
|
body: { completion_token: request.completionToken }
|
|
1236
1276
|
};
|
|
1277
|
+
case "attention.seen":
|
|
1278
|
+
return {
|
|
1279
|
+
/*
|
|
1280
|
+
* A path with no `{session_id}` in it, deliberately: the batch spans
|
|
1281
|
+
* sessions, and `/v1/desktop/attention/seen` cannot be shadowed by the
|
|
1282
|
+
* per-session route at any registration order. The body is snake_case
|
|
1283
|
+
* like every other route in that module, and the conversation identity
|
|
1284
|
+
* is NEVER taken from the client — the backend derives `session/<id>`
|
|
1285
|
+
* from the validated session id, the rule `/seen` already follows.
|
|
1286
|
+
*/
|
|
1287
|
+
path: "/v1/desktop/attention/seen",
|
|
1288
|
+
method: "POST",
|
|
1289
|
+
body: {
|
|
1290
|
+
items: request.items.map((item) => ({
|
|
1291
|
+
session_id: item.sessionId,
|
|
1292
|
+
completion_token: item.completionToken
|
|
1293
|
+
}))
|
|
1294
|
+
}
|
|
1295
|
+
};
|
|
1237
1296
|
case "sessions.notified":
|
|
1238
1297
|
return {
|
|
1239
1298
|
path: `/v1/desktop/sessions/${request.sessionId}/notified`,
|
|
@@ -2657,7 +2716,20 @@ function parsePendingServerUpdateMarker(raw) {
|
|
|
2657
2716
|
return {
|
|
2658
2717
|
before: typeof parsed.before === "string" ? parsed.before : null,
|
|
2659
2718
|
target: typeof parsed.target === "string" ? parsed.target : null,
|
|
2660
|
-
startedAt: parsed.startedAt
|
|
2719
|
+
startedAt: parsed.startedAt,
|
|
2720
|
+
// The three identity fields are additive: a record written before they
|
|
2721
|
+
// existed parses as "no evidence", which is exactly what the evaluator
|
|
2722
|
+
// bounds by the deadline rather than by a pid.
|
|
2723
|
+
deadlineAt: typeof parsed.deadlineAt === "string" ? parsed.deadlineAt : null,
|
|
2724
|
+
groupPid: typeof parsed.groupPid === "number" ? parsed.groupPid : null,
|
|
2725
|
+
groupStartedAt: typeof parsed.groupStartedAt === "string" ? parsed.groupStartedAt : null,
|
|
2726
|
+
/*
|
|
2727
|
+
* The install the attempt ran, and the reason it is parsed rather than dropped:
|
|
2728
|
+
* the reader has no press in scope, so the record is the ONLY thing that can tell
|
|
2729
|
+
* it which tree to ask about. A record without it is an older build's, and the
|
|
2730
|
+
* reader says so and falls back rather than guessing silently.
|
|
2731
|
+
*/
|
|
2732
|
+
installPath: typeof parsed.installPath === "string" ? parsed.installPath : null
|
|
2661
2733
|
};
|
|
2662
2734
|
} catch {
|
|
2663
2735
|
return null;
|
|
@@ -2678,6 +2750,29 @@ function clearPendingServerUpdateMarker(dir) {
|
|
|
2678
2750
|
fs.rmSync(path2, { force: true });
|
|
2679
2751
|
return true;
|
|
2680
2752
|
}
|
|
2753
|
+
const PENDING_SERVER_UPDATE_GRACE_MS = 10 * 6e4;
|
|
2754
|
+
function evaluatePendingServerUpdateMarker(input) {
|
|
2755
|
+
const marker = input.marker;
|
|
2756
|
+
if (!marker) return { kind: "none" };
|
|
2757
|
+
if (marker.groupPid === null) {
|
|
2758
|
+
return { kind: "expired", marker, reason: "unproven" };
|
|
2759
|
+
}
|
|
2760
|
+
if (!input.groupAlive) {
|
|
2761
|
+
return { kind: "expired", marker, reason: "gone" };
|
|
2762
|
+
}
|
|
2763
|
+
const recorded = marker.groupStartedAt;
|
|
2764
|
+
const live = input.liveGroupStamp ?? null;
|
|
2765
|
+
if (recorded && live) {
|
|
2766
|
+
return recorded === live ? { kind: "running", marker, reason: "owned" } : { kind: "expired", marker, reason: "foreign-pid" };
|
|
2767
|
+
}
|
|
2768
|
+
const deadline2 = Date.parse(marker.deadlineAt ?? "");
|
|
2769
|
+
const now = input.now ?? Date.now();
|
|
2770
|
+
const grace = input.graceMs ?? PENDING_SERVER_UPDATE_GRACE_MS;
|
|
2771
|
+
if (Number.isFinite(deadline2) && now > deadline2 + grace) {
|
|
2772
|
+
return { kind: "expired", marker, reason: "expired" };
|
|
2773
|
+
}
|
|
2774
|
+
return { kind: "running", marker, reason: "unproven" };
|
|
2775
|
+
}
|
|
2681
2776
|
const LAST_INSTALL_ATTEMPT_FILE = "last-update-install.json";
|
|
2682
2777
|
function lastInstallAttemptPath(dir) {
|
|
2683
2778
|
return path$1.join(dir, LAST_INSTALL_ATTEMPT_FILE);
|
|
@@ -3277,6 +3372,45 @@ function readSourceRef(prefix) {
|
|
|
3277
3372
|
return null;
|
|
3278
3373
|
}
|
|
3279
3374
|
}
|
|
3375
|
+
function readSourceMarkerState(prefix) {
|
|
3376
|
+
if (!prefix) return { ref: null, mtimeMs: null };
|
|
3377
|
+
const marker = path$1.join(prefix, ".lop-source");
|
|
3378
|
+
try {
|
|
3379
|
+
const stat = fs.statSync(marker);
|
|
3380
|
+
return { ref: readSourceRef(prefix), mtimeMs: stat.mtimeMs };
|
|
3381
|
+
} catch {
|
|
3382
|
+
return { ref: null, mtimeMs: null };
|
|
3383
|
+
}
|
|
3384
|
+
}
|
|
3385
|
+
function didSourceRebuildLand(input) {
|
|
3386
|
+
const { before, after } = input;
|
|
3387
|
+
const hadMarker = before.ref !== null || before.mtimeMs !== null;
|
|
3388
|
+
const hasMarker = after.ref !== null || after.mtimeMs !== null;
|
|
3389
|
+
if (!hasMarker) return false;
|
|
3390
|
+
if (!hadMarker) return true;
|
|
3391
|
+
if (before.ref !== null && after.ref !== null) {
|
|
3392
|
+
return before.ref !== after.ref;
|
|
3393
|
+
}
|
|
3394
|
+
if (before.mtimeMs !== null && after.mtimeMs !== null) {
|
|
3395
|
+
return after.mtimeMs > before.mtimeMs;
|
|
3396
|
+
}
|
|
3397
|
+
return false;
|
|
3398
|
+
}
|
|
3399
|
+
const GUARD_BYPASS_ADVICE = /--skip-[a-z-]+|--allow-downgrade|--force\b|--no-verify|--ignore-checks/i;
|
|
3400
|
+
const TERMINAL_INSTRUCTION = /^(?!\S+:\s)(?:git|lop-update|uv|pip|pipx|python\d?|sudo)\b/i;
|
|
3401
|
+
function installDiagnosisLines(text, maxLines = 6) {
|
|
3402
|
+
const lines = text.split("\n").map((line) => line.trim()).filter((line) => line.length > 0);
|
|
3403
|
+
if (lines.length === 0) return "";
|
|
3404
|
+
const allowed = lines.filter((line) => !GUARD_BYPASS_ADVICE.test(line));
|
|
3405
|
+
if (allowed.length === 0) return "";
|
|
3406
|
+
const head = [];
|
|
3407
|
+
for (const line of allowed) {
|
|
3408
|
+
if (head.length >= maxLines) break;
|
|
3409
|
+
if (head.length > 0 && TERMINAL_INSTRUCTION.test(line)) break;
|
|
3410
|
+
head.push(line);
|
|
3411
|
+
}
|
|
3412
|
+
return head.join("\n").slice(0, 800);
|
|
3413
|
+
}
|
|
3280
3414
|
const LOCAL_OPERATOR_DIST_INFO_VERSION = /^local[-_]operator[-_](.+)\.dist-info$/;
|
|
3281
3415
|
function newerDistInfoVersion(candidate, current) {
|
|
3282
3416
|
if (current === null) return true;
|
|
@@ -3501,8 +3635,14 @@ function classifyGlobalInstall(identity) {
|
|
|
3501
3635
|
if (identity.installer === "pip") return "pip";
|
|
3502
3636
|
return "global-unknown";
|
|
3503
3637
|
}
|
|
3638
|
+
function installPrefixOf(identity) {
|
|
3639
|
+
if (identity.venvPrefix) return identity.venvPrefix;
|
|
3640
|
+
const script = identity.realPath ?? identity.path;
|
|
3641
|
+
return script ? path$1.dirname(path$1.dirname(script)) : null;
|
|
3642
|
+
}
|
|
3504
3643
|
function resolveGlobalInstallPlan(input) {
|
|
3505
3644
|
const kind = classifyGlobalInstall(input.identity);
|
|
3645
|
+
const installPrefix = installPrefixOf(input.identity);
|
|
3506
3646
|
const sourceBuild = kind === "editable" || kind === "uv-tool" && isSourceBuildRef(input.identity.sourceRef);
|
|
3507
3647
|
const detail = input.identity.path ? `local-operator resolves to ${input.identity.path}${input.identity.realPath && input.identity.realPath !== input.identity.path ? ` (${input.identity.realPath})` : ""}, classified as ${kind}.${kind === "editable" ? " Built from source on this machine, so it follows the checkout rather than the published release." : ""}` : "local-operator was not found on PATH.";
|
|
3508
3648
|
if (kind === "editable") {
|
|
@@ -3511,15 +3651,18 @@ function resolveGlobalInstallPlan(input) {
|
|
|
3511
3651
|
updateCommand: "",
|
|
3512
3652
|
remedy: "The server is running from a source checkout on this machine rather than an installed copy, so update it with lop-update after your change is merged.",
|
|
3513
3653
|
detail,
|
|
3514
|
-
sourceBuild: true
|
|
3654
|
+
sourceBuild: true,
|
|
3655
|
+
managedRoute: null,
|
|
3656
|
+
installPrefix
|
|
3515
3657
|
};
|
|
3516
3658
|
}
|
|
3517
3659
|
if (kind === "uv-tool") {
|
|
3518
3660
|
const managed = generationInstallRoot(input.identity) !== null;
|
|
3519
|
-
const
|
|
3661
|
+
const sourceRebuildRoute = !managed && sourceBuild && (input.sourceRebuild ?? null) !== null;
|
|
3662
|
+
const provenance = sourceBuild ? " source build of this machine's checkout; `lop update` would install the published release over it." : "";
|
|
3520
3663
|
return {
|
|
3521
|
-
canManageUpdate: managed,
|
|
3522
|
-
updateCommand: "lop update",
|
|
3664
|
+
canManageUpdate: managed || sourceRebuildRoute,
|
|
3665
|
+
updateCommand: sourceRebuildRoute ? "lop-update" : "lop update",
|
|
3523
3666
|
/*
|
|
3524
3667
|
* The remedy is the sentence above the command well in the by-hand panel
|
|
3525
3668
|
* and the consequence line in the managed offer, and each arm says the
|
|
@@ -3532,6 +3675,10 @@ function resolveGlobalInstallPlan(input) {
|
|
|
3532
3675
|
* destructive thing this path does, so it is stated here rather than in
|
|
3533
3676
|
* the in-flight panel the user reaches only after pressing.
|
|
3534
3677
|
*
|
|
3678
|
+
* Source build - the same disclosure with the one difference that matters:
|
|
3679
|
+
* what is rebuilt is THEIR checkout, and the version they end up on is the
|
|
3680
|
+
* checkout's rather than the release the app offered.
|
|
3681
|
+
*
|
|
3535
3682
|
* Legacy - WHY the app will not press the button, which is what the user
|
|
3536
3683
|
* is choosing between (reviews U8, N1). It lived only in the mono Details
|
|
3537
3684
|
* line, trailing a resolved path and a classification.
|
|
@@ -3540,9 +3687,11 @@ function resolveGlobalInstallPlan(input) {
|
|
|
3540
3687
|
* panels, and in the by-hand panel the next line was the version sentence,
|
|
3541
3688
|
* so the promise landed on the wrong line (review D5).
|
|
3542
3689
|
*/
|
|
3543
|
-
remedy: managed ? "The app updates this install and then restarts the server it started, so a turn that is in flight is dropped while the server comes back. This can take a minute or two." : "This install predates the non-disruptive installer, so update it once from your terminal. This install's updater rewrites the shared environment in place, which can interrupt sessions mid-turn; the app manages updates after that.",
|
|
3544
|
-
detail: `${detail}${provenance}`,
|
|
3545
|
-
sourceBuild
|
|
3690
|
+
remedy: managed ? "The app updates this install and then restarts the server it started, so a turn that is in flight is dropped while the server comes back. This can take a minute or two." : sourceRebuildRoute ? "Rebuilds this checkout with `lop-update`. The rebuild happens in place, so sessions on this machine can be interrupted while it runs, and it can take up to half an hour. This install keeps reporting the checkout's version, not the release the app offered." : "This install predates the non-disruptive installer, so update it once from your terminal. This install's updater rewrites the shared environment in place, which can interrupt sessions mid-turn; the app manages updates after that.",
|
|
3691
|
+
detail: `${detail}${managed ? provenance : sourceRebuildRoute ? " source build of this machine's checkout; an in-place rebuild." : provenance}`,
|
|
3692
|
+
sourceBuild,
|
|
3693
|
+
managedRoute: managed ? "entry-point" : sourceRebuildRoute ? "source-build" : null,
|
|
3694
|
+
installPrefix
|
|
3546
3695
|
};
|
|
3547
3696
|
}
|
|
3548
3697
|
if (kind === "pipx") {
|
|
@@ -3551,7 +3700,9 @@ function resolveGlobalInstallPlan(input) {
|
|
|
3551
3700
|
updateCommand: "pipx upgrade local-operator",
|
|
3552
3701
|
remedy: "The server is a pipx install, so update it from your terminal",
|
|
3553
3702
|
detail,
|
|
3554
|
-
sourceBuild: false
|
|
3703
|
+
sourceBuild: false,
|
|
3704
|
+
managedRoute: null,
|
|
3705
|
+
installPrefix
|
|
3555
3706
|
};
|
|
3556
3707
|
}
|
|
3557
3708
|
if (kind === "pip") {
|
|
@@ -3560,7 +3711,9 @@ function resolveGlobalInstallPlan(input) {
|
|
|
3560
3711
|
updateCommand: "pip install --upgrade local-operator",
|
|
3561
3712
|
remedy: "The server is a pip install, so update it from your terminal",
|
|
3562
3713
|
detail,
|
|
3563
|
-
sourceBuild: false
|
|
3714
|
+
sourceBuild: false,
|
|
3715
|
+
managedRoute: null,
|
|
3716
|
+
installPrefix
|
|
3564
3717
|
};
|
|
3565
3718
|
}
|
|
3566
3719
|
return {
|
|
@@ -3568,7 +3721,9 @@ function resolveGlobalInstallPlan(input) {
|
|
|
3568
3721
|
updateCommand: "",
|
|
3569
3722
|
remedy: "The app could not tell how this server was installed, so update it with the tool you installed it with - uv, pipx or pip",
|
|
3570
3723
|
detail,
|
|
3571
|
-
sourceBuild: false
|
|
3724
|
+
sourceBuild: false,
|
|
3725
|
+
managedRoute: null,
|
|
3726
|
+
installPrefix
|
|
3572
3727
|
};
|
|
3573
3728
|
}
|
|
3574
3729
|
const PINNABLE_VERSION_REGEX = /^(?:\d+!)?\d+(?:\.\d+)*(?:(?:a|b|rc)\d+)?(?:\.post\d+)?(?:\.dev\d+)?(?:[-+][0-9A-Za-z.]+)?$/;
|
|
@@ -14982,16 +15137,16 @@ function createSessionCookieQuitHold(deps) {
|
|
|
14982
15137
|
};
|
|
14983
15138
|
}
|
|
14984
15139
|
const OPERATION_ID = /^[a-zA-Z0-9_-]{1,128}$/;
|
|
15140
|
+
const FOREGROUND_RECEIPT_OPS = /* @__PURE__ */ new Set([
|
|
15141
|
+
"sessions.seen",
|
|
15142
|
+
"attention.seen"
|
|
15143
|
+
]);
|
|
14985
15144
|
function guardForegroundReceipts(window, send2) {
|
|
14986
15145
|
return (input) => {
|
|
14987
|
-
if (input !== null && typeof input === "object" && "op" in input && input.op === "
|
|
15146
|
+
if (input !== null && typeof input === "object" && "op" in input && typeof input.op === "string" && FOREGROUND_RECEIPT_OPS.has(input.op)) {
|
|
14988
15147
|
const owner = window();
|
|
14989
15148
|
if (!owner || owner.isDestroyed() || !owner.isVisible() || owner.isMinimized() || !owner.isFocused()) {
|
|
14990
|
-
return Promise.reject(
|
|
14991
|
-
new Error(
|
|
14992
|
-
"View this completion in the foreground before marking it read."
|
|
14993
|
-
)
|
|
14994
|
-
);
|
|
15149
|
+
return Promise.reject(new Error(DESKTOP_FOREGROUND_REQUIRED_MESSAGE));
|
|
14995
15150
|
}
|
|
14996
15151
|
}
|
|
14997
15152
|
return send2(input);
|
|
@@ -16554,9 +16709,176 @@ function clauseTextOf(message, start, end) {
|
|
|
16554
16709
|
function stripErrorPrefixes(message) {
|
|
16555
16710
|
return message.replace(LEADING_ERROR_PREFIXES, "").replace(EMBEDDED_ERROR_PREFIX, ": ");
|
|
16556
16711
|
}
|
|
16712
|
+
const GROUP_SIGNAL_GRACE_MS = 5e3;
|
|
16713
|
+
const GROUP_EXIT_WAIT_MS = 2e4;
|
|
16714
|
+
function isInstallGroupAlive(pid) {
|
|
16715
|
+
try {
|
|
16716
|
+
process.kill(-pid, 0);
|
|
16717
|
+
return true;
|
|
16718
|
+
} catch (error) {
|
|
16719
|
+
return error.code === "EPERM";
|
|
16720
|
+
}
|
|
16721
|
+
}
|
|
16722
|
+
function readProcessStartStamp(pid) {
|
|
16723
|
+
if (!Number.isInteger(pid) || pid <= 0) return null;
|
|
16724
|
+
try {
|
|
16725
|
+
const result = node_child_process.spawnSync("ps", ["-o", "lstart=", "-p", String(pid)], {
|
|
16726
|
+
encoding: "utf8",
|
|
16727
|
+
timeout: 2e3
|
|
16728
|
+
});
|
|
16729
|
+
if (result.status !== 0 || typeof result.stdout !== "string") return null;
|
|
16730
|
+
const stamp = result.stdout.trim();
|
|
16731
|
+
return stamp.length > 0 ? stamp : null;
|
|
16732
|
+
} catch {
|
|
16733
|
+
return null;
|
|
16734
|
+
}
|
|
16735
|
+
}
|
|
16736
|
+
function signalInstallGroup(pid, signal) {
|
|
16737
|
+
try {
|
|
16738
|
+
process.kill(-pid, signal);
|
|
16739
|
+
return true;
|
|
16740
|
+
} catch {
|
|
16741
|
+
return false;
|
|
16742
|
+
}
|
|
16743
|
+
}
|
|
16744
|
+
async function waitForInstallGroupGone(pid, timeoutMs = GROUP_EXIT_WAIT_MS) {
|
|
16745
|
+
const started = Date.now();
|
|
16746
|
+
while (Date.now() - started < timeoutMs) {
|
|
16747
|
+
if (!isInstallGroupAlive(pid)) return true;
|
|
16748
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
16749
|
+
}
|
|
16750
|
+
return !isInstallGroupAlive(pid);
|
|
16751
|
+
}
|
|
16752
|
+
function runInOwnProcessGroup(input) {
|
|
16753
|
+
const graceMs = input.graceMs ?? GROUP_SIGNAL_GRACE_MS;
|
|
16754
|
+
const log = input.log ?? (() => {
|
|
16755
|
+
});
|
|
16756
|
+
return new Promise((resolve) => {
|
|
16757
|
+
let settled = false;
|
|
16758
|
+
let timedOut = false;
|
|
16759
|
+
let stdout = "";
|
|
16760
|
+
let stderr = "";
|
|
16761
|
+
let groupPid = null;
|
|
16762
|
+
let timer = null;
|
|
16763
|
+
let escalation = null;
|
|
16764
|
+
const finish = (result) => {
|
|
16765
|
+
if (settled) return;
|
|
16766
|
+
settled = true;
|
|
16767
|
+
if (timer) clearTimeout(timer);
|
|
16768
|
+
dropStream(child?.stdout);
|
|
16769
|
+
dropStream(child?.stderr);
|
|
16770
|
+
resolve({ ...result, groupPid });
|
|
16771
|
+
};
|
|
16772
|
+
let child = null;
|
|
16773
|
+
try {
|
|
16774
|
+
child = node_child_process.spawn(input.command, input.args, {
|
|
16775
|
+
detached: true,
|
|
16776
|
+
cwd: input.cwd,
|
|
16777
|
+
...input.env ? { env: input.env } : {},
|
|
16778
|
+
// No inherited stdin: the front end is non-interactive, and an
|
|
16779
|
+
// inherited terminal on a GUI process is a handle to nothing.
|
|
16780
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
16781
|
+
});
|
|
16782
|
+
} catch (error) {
|
|
16783
|
+
log(
|
|
16784
|
+
`Could not start ${input.command}: ${error.message}`,
|
|
16785
|
+
"error"
|
|
16786
|
+
);
|
|
16787
|
+
finish({
|
|
16788
|
+
exitCode: null,
|
|
16789
|
+
stdout,
|
|
16790
|
+
stderr: error.message,
|
|
16791
|
+
ran: false,
|
|
16792
|
+
timedOut: false,
|
|
16793
|
+
groupStillRunning: false
|
|
16794
|
+
});
|
|
16795
|
+
return;
|
|
16796
|
+
}
|
|
16797
|
+
groupPid = child.pid ?? null;
|
|
16798
|
+
if (groupPid !== null) input.onSpawn?.(groupPid);
|
|
16799
|
+
const record = (chunk, stream) => {
|
|
16800
|
+
const text = String(chunk);
|
|
16801
|
+
if (stream === "stdout") {
|
|
16802
|
+
stdout = (stdout + text).slice(-4194304);
|
|
16803
|
+
} else {
|
|
16804
|
+
stderr = (stderr + text).slice(-4194304);
|
|
16805
|
+
}
|
|
16806
|
+
};
|
|
16807
|
+
child.stdout?.on("data", (chunk) => record(chunk, "stdout"));
|
|
16808
|
+
child.stderr?.on("data", (chunk) => record(chunk, "stderr"));
|
|
16809
|
+
child.on("error", (error) => {
|
|
16810
|
+
log(`The updater could not be run: ${error.message}`, "error");
|
|
16811
|
+
finish({
|
|
16812
|
+
exitCode: null,
|
|
16813
|
+
stdout,
|
|
16814
|
+
stderr: stderr.length > 0 ? stderr : error.message,
|
|
16815
|
+
ran: false,
|
|
16816
|
+
timedOut: false,
|
|
16817
|
+
groupStillRunning: false
|
|
16818
|
+
});
|
|
16819
|
+
});
|
|
16820
|
+
child.on("close", (code) => {
|
|
16821
|
+
finish({
|
|
16822
|
+
exitCode: code,
|
|
16823
|
+
stdout,
|
|
16824
|
+
stderr,
|
|
16825
|
+
ran: true,
|
|
16826
|
+
timedOut,
|
|
16827
|
+
groupStillRunning: false
|
|
16828
|
+
});
|
|
16829
|
+
});
|
|
16830
|
+
timer = setTimeout(() => {
|
|
16831
|
+
timedOut = true;
|
|
16832
|
+
log(
|
|
16833
|
+
`The updater did not finish within ${input.timeoutMs}ms; stopping the process group this run started. Its own children (uv, pipx, pip) run inside that group, so signalling only the front end would leave the installer running`,
|
|
16834
|
+
"warn"
|
|
16835
|
+
);
|
|
16836
|
+
if (groupPid !== null) signalInstallGroup(groupPid, "SIGTERM");
|
|
16837
|
+
escalation = setTimeout(() => {
|
|
16838
|
+
if (groupPid === null || !isInstallGroupAlive(groupPid)) return;
|
|
16839
|
+
log(
|
|
16840
|
+
`The updater's process group was still alive ${graceMs}ms after the stop signal; killing it`,
|
|
16841
|
+
"warn"
|
|
16842
|
+
);
|
|
16843
|
+
signalInstallGroup(groupPid, "SIGKILL");
|
|
16844
|
+
}, graceMs);
|
|
16845
|
+
escalation.unref?.();
|
|
16846
|
+
finish({
|
|
16847
|
+
exitCode: null,
|
|
16848
|
+
stdout,
|
|
16849
|
+
stderr,
|
|
16850
|
+
ran: false,
|
|
16851
|
+
timedOut: true,
|
|
16852
|
+
groupStillRunning: groupPid !== null && isInstallGroupAlive(groupPid)
|
|
16853
|
+
});
|
|
16854
|
+
}, input.timeoutMs);
|
|
16855
|
+
});
|
|
16856
|
+
}
|
|
16857
|
+
function dropStream(stream) {
|
|
16858
|
+
const destroy = stream?.destroy;
|
|
16859
|
+
if (typeof destroy === "function") destroy.call(stream);
|
|
16860
|
+
}
|
|
16861
|
+
function serverUpdateFailureRemedy(input) {
|
|
16862
|
+
if (input.rebuildRoute && /REFUSING to release a stale ref/i.test(input.diagnosis)) {
|
|
16863
|
+
return "This checkout is behind its remote, which is what `lop-update` refused to build from: bring the checkout up to date, and the next press here will build it.";
|
|
16864
|
+
}
|
|
16865
|
+
if (input.rebuildRoute) {
|
|
16866
|
+
return "A rebuild changes this install only once it succeeds, so nothing was restarted.";
|
|
16867
|
+
}
|
|
16868
|
+
return "Nothing was restarted: the build that was serving is the build still serving.";
|
|
16869
|
+
}
|
|
16870
|
+
function serverUpdateFailureSentence(input) {
|
|
16871
|
+
const headline = !input.ran ? "The server update did not finish: the installer could not be run to a verdict." : input.exitCode !== 0 ? `The server update did not install: \`${input.rebuildRoute ? "lop-update" : "lop update"}\` exited ${input.exitCode}.` : input.rebuildRoute ? "The rebuild finished but did not change this install: its `.lop-source` still names the same revision." : `The server update to ${input.target ?? "the new release"} did not take effect: the install still reports ${input.after ?? input.before ?? "its previous version"}.`;
|
|
16872
|
+
const stopped = input.groupSurvived ? " The updater was stopped; something it started may still be replacing the install." : "";
|
|
16873
|
+
const pointer = input.diagnosis ? " The installer's own output is below." : " The installer's own output is in the update service log.";
|
|
16874
|
+
const escapeHatch = input.rebuildRoute ? ` You can also run \`${input.updateCommand || "lop-update"}\` yourself in a terminal.` : "";
|
|
16875
|
+
return `${headline}${stopped} ${serverUpdateFailureRemedy(input)}${pointer}${escapeHatch}`;
|
|
16876
|
+
}
|
|
16557
16877
|
const VERSION_LINE_REGEX = /Version:\s*([^\n]+)/;
|
|
16558
16878
|
const VERSION_CLEAN_REGEX = /^v/i;
|
|
16559
16879
|
const BETA_VERSION_REGEX = /v\d+\.\d+\.\d+\.beta\.\d+/;
|
|
16880
|
+
const GLOBAL_UPDATE_TIMEOUT_MS = 15 * 60 * 1e3;
|
|
16881
|
+
const SOURCE_REBUILD_TIMEOUT_MS = 30 * 60 * 1e3;
|
|
16560
16882
|
const BUNDLE_ID_REGEX = /^[\w.-]+$/;
|
|
16561
16883
|
const INSTALL_IN_FLIGHT_RECHECK_MS = 1e4;
|
|
16562
16884
|
const LAUNCHCTL_NOT_LOADED_REGEX = /could not find|no such process/i;
|
|
@@ -16641,10 +16963,6 @@ const DRIFT_ABSENCE_REPORT = {
|
|
|
16641
16963
|
"running-ahead": "the server is running a build NEWER than the install on disk (a downgrade on disk), so it is left alone",
|
|
16642
16964
|
unorderable: "the two readings cannot be ordered against each other, so nothing is restarted on a guess"
|
|
16643
16965
|
};
|
|
16644
|
-
function stderrTail(stderr, maxLines = 4) {
|
|
16645
|
-
const lines = stderr.split("\n").map((line) => line.trim()).filter((line) => line.length > 0);
|
|
16646
|
-
return lines.slice(-maxLines).join("\n").slice(-600);
|
|
16647
|
-
}
|
|
16648
16966
|
function readingOfHealth(health) {
|
|
16649
16967
|
const result = health.result;
|
|
16650
16968
|
return {
|
|
@@ -18814,9 +19132,22 @@ ${heal.removed.join("\n")}`,
|
|
|
18814
19132
|
* machines this check exists for. Null when the prefix carries no console
|
|
18815
19133
|
* script, which is "we could not classify it" rather than a guess.
|
|
18816
19134
|
*/
|
|
18817
|
-
|
|
19135
|
+
/**
|
|
19136
|
+
* The console script an install root exposes, or null when it has none.
|
|
19137
|
+
*
|
|
19138
|
+
* ONE PLACE, because two callers must agree: the identity the app CLASSIFIES an
|
|
19139
|
+
* install by, and the command it RUNS for that install. They used to be derived
|
|
19140
|
+
* separately - the check read `<prefix>/bin/local-operator` while the press ran
|
|
19141
|
+
* whatever the shim resolved to - which is how the two halves came to describe
|
|
19142
|
+
* different installs (review round 5, Q-1 = M2).
|
|
19143
|
+
*/
|
|
19144
|
+
servingConsoleScript(prefix) {
|
|
18818
19145
|
const script = process.platform === "win32" ? path$1.join(prefix, "Scripts", "local-operator.exe") : path$1.join(prefix, "bin", "local-operator");
|
|
18819
|
-
return fs.existsSync(script) ?
|
|
19146
|
+
return fs.existsSync(script) ? script : null;
|
|
19147
|
+
}
|
|
19148
|
+
servingInstallIdentity(prefix) {
|
|
19149
|
+
const script = this.servingConsoleScript(prefix);
|
|
19150
|
+
return script === null ? null : readInstallIdentity(script);
|
|
18820
19151
|
}
|
|
18821
19152
|
/**
|
|
18822
19153
|
* Decide what the backend update prompt may offer for a startup mode.
|
|
@@ -18848,6 +19179,15 @@ ${heal.removed.join("\n")}`,
|
|
|
18848
19179
|
return {
|
|
18849
19180
|
canManageUpdate: false,
|
|
18850
19181
|
updateCommand: "",
|
|
19182
|
+
/*
|
|
19183
|
+
* NO ROUTE AND NO PREFIX: this arm names no installer, which is the whole point
|
|
19184
|
+
* of the sentence above - there is nothing here for the app to run, so the
|
|
19185
|
+
* managed-route fields are empty rather than pointing at the managed tree.
|
|
19186
|
+
* (This arm is upstream's, arrived in the fold; the fields are this branch's,
|
|
19187
|
+
* and the type is what required them to be stated.)
|
|
19188
|
+
*/
|
|
19189
|
+
managedRoute: null,
|
|
19190
|
+
installPrefix: null,
|
|
18851
19191
|
remedy: "This server is running from Local Operator's own managed environment, which no package manager owns - so there is no terminal command that can update it correctly. The app can only update a server it started itself: stop this one, then start Local Operator again and let it start its own.",
|
|
18852
19192
|
detail,
|
|
18853
19193
|
sourceBuild: false,
|
|
@@ -18858,7 +19198,10 @@ ${heal.removed.join("\n")}`,
|
|
|
18858
19198
|
}
|
|
18859
19199
|
const servingIdentity = serving.prefix !== null ? this.servingInstallIdentity(serving.prefix) : null;
|
|
18860
19200
|
const identity = servingIdentity ?? await this.resolveInstallIdentity();
|
|
18861
|
-
const plan = resolveGlobalInstallPlan({
|
|
19201
|
+
const plan = resolveGlobalInstallPlan({
|
|
19202
|
+
identity,
|
|
19203
|
+
sourceRebuild: resolveCommandPath("lop-update")
|
|
19204
|
+
});
|
|
18862
19205
|
logger.info(
|
|
18863
19206
|
`External backend install (${startupMode}): ${plan.detail}; remedy is \`${plan.updateCommand || "nothing the app can name"}\`; install version ${serving.version ?? identity.version ?? "unreadable"}`,
|
|
18864
19207
|
LogFileType.UPDATE_SERVICE
|
|
@@ -18875,7 +19218,9 @@ ${heal.removed.join("\n")}`,
|
|
|
18875
19218
|
* Either reading of the serving root proves the subject: its dist-info's
|
|
18876
19219
|
* version, or the identity read from its own console script.
|
|
18877
19220
|
*/
|
|
18878
|
-
installedInstallVersionDescribesServingInstall: serving.version !== null || servingIdentity !== null
|
|
19221
|
+
installedInstallVersionDescribesServingInstall: serving.version !== null || servingIdentity !== null,
|
|
19222
|
+
managedRoute: plan.managedRoute,
|
|
19223
|
+
installPrefix: plan.installPrefix
|
|
18879
19224
|
};
|
|
18880
19225
|
}
|
|
18881
19226
|
return {
|
|
@@ -18913,9 +19258,25 @@ ${heal.removed.join("\n")}`,
|
|
|
18913
19258
|
* construction - it is read FROM the root the server named - so it describes the
|
|
18914
19259
|
* subject whenever there is a reading at all.
|
|
18915
19260
|
*/
|
|
18916
|
-
installedInstallVersionDescribesServingInstall: serving.version !== null
|
|
19261
|
+
installedInstallVersionDescribesServingInstall: serving.version !== null,
|
|
19262
|
+
managedRoute: "entry-point",
|
|
19263
|
+
installPrefix: null
|
|
18917
19264
|
};
|
|
18918
19265
|
}
|
|
19266
|
+
/**
|
|
19267
|
+
* `.lop-source` for the install currently resolved, read from a fresh resolution.
|
|
19268
|
+
*
|
|
19269
|
+
* Re-resolved on every call rather than captured with the plan, for the reason
|
|
19270
|
+
* this whole path re-reads its evidence: between a check and a click the install
|
|
19271
|
+
* can move (a generations swap re-points `current`, and `lop-update` rewrites the
|
|
19272
|
+
* uv tool root), so a prefix held from minutes ago can name a tree nothing is
|
|
19273
|
+
* using any more. The reading is what `didSourceRebuildLand` compares, in both
|
|
19274
|
+
* directions.
|
|
19275
|
+
*/
|
|
19276
|
+
readRebuildMarkerState(installPath) {
|
|
19277
|
+
const identity = readInstallIdentity(installPath);
|
|
19278
|
+
return readSourceMarkerState(identity.venvPrefix ?? null);
|
|
19279
|
+
}
|
|
18919
19280
|
/**
|
|
18920
19281
|
* Path the `local-operator` shim resolves to, or null when it is not installed.
|
|
18921
19282
|
*
|
|
@@ -19684,15 +20045,24 @@ ${heal.removed.join("\n")}`,
|
|
|
19684
20045
|
}
|
|
19685
20046
|
/**
|
|
19686
20047
|
* The version the resolved global install reports on disk, or null.
|
|
20048
|
+
* The version an install reports on disk, or null.
|
|
20049
|
+
*
|
|
20050
|
+
* Shell-free, and deliberately a different read from the running backend's `/health`:
|
|
20051
|
+
* the install is what an update moves, and immediately after one it is the only reading
|
|
20052
|
+
* that has moved - the daemon lags until it is restarted.
|
|
19687
20053
|
*
|
|
19688
|
-
*
|
|
19689
|
-
*
|
|
19690
|
-
*
|
|
19691
|
-
*
|
|
19692
|
-
* the
|
|
20054
|
+
* THE INSTALL IS A REQUIRED ARGUMENT, and that is a fix rather than a style. This read
|
|
20055
|
+
* used to default to `resolveLocalOperatorPath()` - the shim - and that default is how
|
|
20056
|
+
* the same seam kept arriving: a caller that forgot which install its attempt had run
|
|
20057
|
+
* silently read the shim instead, and four review rounds found it one site at a time
|
|
20058
|
+
* (the press against the check, the two evidence reads, then the unattended
|
|
20059
|
+
* reconciliation). A required argument turns that mistake into a compile error rather
|
|
20060
|
+
* than into a plausible number. The one caller with a record and no path - the
|
|
20061
|
+
* reconciliation reading a marker an older build wrote - names its fallback explicitly
|
|
20062
|
+
* and logs it.
|
|
19693
20063
|
*/
|
|
19694
|
-
|
|
19695
|
-
return readInstallIdentity(
|
|
20064
|
+
readInstallVersionAt(installPath) {
|
|
20065
|
+
return readInstallIdentity(installPath)?.version ?? null;
|
|
19696
20066
|
}
|
|
19697
20067
|
/**
|
|
19698
20068
|
* Run the install's own front end: `<resolved shim> update`.
|
|
@@ -19718,23 +20088,36 @@ ${heal.removed.join("\n")}`,
|
|
|
19718
20088
|
* cannot land inside the sealed bundle. `shell: true` is never used - this is a
|
|
19719
20089
|
* resolved shim path, not a command line.
|
|
19720
20090
|
*/
|
|
19721
|
-
async runGlobalUpdate(
|
|
20091
|
+
async runGlobalUpdate(command, timeoutMs, onSpawn) {
|
|
19722
20092
|
const updatePath = installerSearchPath(process.env, os.homedir());
|
|
19723
20093
|
logger.info(
|
|
19724
|
-
`Executing the install's own updater: ${
|
|
20094
|
+
`Executing the install's own updater: ${command.path} ${command.args.join(" ")} (PATH: ${updatePath})`,
|
|
19725
20095
|
LogFileType.UPDATE_SERVICE
|
|
19726
20096
|
);
|
|
19727
|
-
const run = await
|
|
19728
|
-
|
|
19729
|
-
|
|
20097
|
+
const run = await runInOwnProcessGroup({
|
|
20098
|
+
command: command.path,
|
|
20099
|
+
args: command.args,
|
|
20100
|
+
timeoutMs,
|
|
20101
|
+
// A neutral working directory, never the app's resources dir, so an updater
|
|
20102
|
+
// that resolves a relative path from where it stands cannot land inside the
|
|
20103
|
+
// sealed bundle.
|
|
20104
|
+
cwd: os.homedir(),
|
|
20105
|
+
env: { ...this.pythonSpawnEnv(), PATH: updatePath },
|
|
20106
|
+
onSpawn,
|
|
20107
|
+
log: (line, level) => {
|
|
20108
|
+
if (level === "error") logger.error(line, LogFileType.UPDATE_SERVICE);
|
|
20109
|
+
else if (level === "warn")
|
|
20110
|
+
logger.warn(line, LogFileType.UPDATE_SERVICE);
|
|
20111
|
+
else logger.info(line, LogFileType.UPDATE_SERVICE);
|
|
20112
|
+
}
|
|
19730
20113
|
});
|
|
19731
20114
|
logger.info(
|
|
19732
|
-
|
|
20115
|
+
`${command.path} stdout: ${run.stdout.trim() || "(empty)"}`,
|
|
19733
20116
|
LogFileType.UPDATE_SERVICE
|
|
19734
20117
|
);
|
|
19735
20118
|
if (run.stderr.trim().length > 0) {
|
|
19736
20119
|
logger.warn(
|
|
19737
|
-
|
|
20120
|
+
`${command.path} stderr: ${run.stderr.trim()}`,
|
|
19738
20121
|
LogFileType.UPDATE_SERVICE
|
|
19739
20122
|
);
|
|
19740
20123
|
}
|
|
@@ -19770,6 +20153,36 @@ ${heal.removed.join("\n")}`,
|
|
|
19770
20153
|
* exactly as the pip path does (`didUpgradeLand`).
|
|
19771
20154
|
*/
|
|
19772
20155
|
async updateGlobalInstall(backend, plan, target2) {
|
|
20156
|
+
const recorded = readPendingServerUpdateMarker(this.markerDir());
|
|
20157
|
+
const recordedPid = recorded?.groupPid ?? null;
|
|
20158
|
+
const recordedGroupAlive = recordedPid !== null && isInstallGroupAlive(recordedPid);
|
|
20159
|
+
const outcome = evaluatePendingServerUpdateMarker({
|
|
20160
|
+
marker: recorded,
|
|
20161
|
+
groupAlive: recordedGroupAlive,
|
|
20162
|
+
liveGroupStamp: recordedGroupAlive && recordedPid !== null ? readProcessStartStamp(recordedPid) : null
|
|
20163
|
+
});
|
|
20164
|
+
if (outcome.kind === "expired") {
|
|
20165
|
+
logger.warn(
|
|
20166
|
+
`A recorded server update (${outcome.marker.startedAt}) is not running any more (${outcome.reason}); clearing the record so this attempt can proceed`,
|
|
20167
|
+
LogFileType.UPDATE_SERVICE
|
|
20168
|
+
);
|
|
20169
|
+
clearPendingServerUpdateMarker(this.markerDir());
|
|
20170
|
+
}
|
|
20171
|
+
if (outcome.kind === "running") {
|
|
20172
|
+
logger.warn(
|
|
20173
|
+
`A server update recorded by an earlier launch is still running (group ${outcome.marker.groupPid}, ${outcome.reason}, started ${outcome.marker.startedAt}); refusing to start a second beside it`,
|
|
20174
|
+
LogFileType.UPDATE_SERVICE
|
|
20175
|
+
);
|
|
20176
|
+
const startedAtMs = Date.parse(outcome.marker.startedAt);
|
|
20177
|
+
const ageMinutes = Number.isFinite(startedAtMs) ? Math.max(0, Math.round((Date.now() - startedAtMs) / 6e4)) : null;
|
|
20178
|
+
const age = ageMinutes === null ? "at a time that could not be read" : ageMinutes < 1 ? "moments ago" : `${ageMinutes} minute${ageMinutes === 1 ? "" : "s"} ago`;
|
|
20179
|
+
this.sendToRenderer("backend-update-error", {
|
|
20180
|
+
message: `A server update this app started ${age} is still running (process group ${outcome.marker.groupPid}), so a second one will not be started beside it. If that update is no longer responding, the update service log says what it was doing.`,
|
|
20181
|
+
phase: "update",
|
|
20182
|
+
logPath: serverUpdateLogPath()
|
|
20183
|
+
});
|
|
20184
|
+
return false;
|
|
20185
|
+
}
|
|
19773
20186
|
if (this.serverUpdateInFlight) {
|
|
19774
20187
|
logger.warn(
|
|
19775
20188
|
"A global install update is already running; refusing to start a second beside it",
|
|
@@ -19783,16 +20196,75 @@ ${heal.removed.join("\n")}`,
|
|
|
19783
20196
|
return false;
|
|
19784
20197
|
}
|
|
19785
20198
|
this.serverUpdateInFlight = true;
|
|
20199
|
+
let handedOff = false;
|
|
19786
20200
|
try {
|
|
19787
|
-
|
|
20201
|
+
const landed = await this.runGlobalUpdateAttempt(backend, plan, target2);
|
|
20202
|
+
const stillRunning = this.recordedUpdateStillRunning();
|
|
20203
|
+
if (stillRunning !== null) {
|
|
20204
|
+
handedOff = true;
|
|
20205
|
+
void this.holdGlobalInstallGuardUntilGroupGone(stillRunning);
|
|
20206
|
+
}
|
|
20207
|
+
return landed;
|
|
19788
20208
|
} finally {
|
|
19789
|
-
this.serverUpdateInFlight = false;
|
|
20209
|
+
if (!handedOff) this.serverUpdateInFlight = false;
|
|
19790
20210
|
}
|
|
19791
20211
|
}
|
|
20212
|
+
/**
|
|
20213
|
+
* The group a record on disk still names, or null when nothing is running.
|
|
20214
|
+
*
|
|
20215
|
+
* Read rather than passed, because it is the SAME evidence the next launch reads:
|
|
20216
|
+
* a guard that decided from an in-memory value would disagree with the launch
|
|
20217
|
+
* that follows a crash, which is the whole class this pass is closing.
|
|
20218
|
+
*/
|
|
20219
|
+
recordedUpdateStillRunning() {
|
|
20220
|
+
const recorded = readPendingServerUpdateMarker(this.markerDir());
|
|
20221
|
+
const pid = recorded?.groupPid ?? null;
|
|
20222
|
+
if (pid === null) return null;
|
|
20223
|
+
const alive = isInstallGroupAlive(pid);
|
|
20224
|
+
const outcome = evaluatePendingServerUpdateMarker({
|
|
20225
|
+
marker: recorded,
|
|
20226
|
+
groupAlive: alive,
|
|
20227
|
+
liveGroupStamp: alive ? readProcessStartStamp(pid) : null
|
|
20228
|
+
});
|
|
20229
|
+
return outcome.kind === "running" ? pid : null;
|
|
20230
|
+
}
|
|
20231
|
+
/**
|
|
20232
|
+
* Hold the install guard until a signalled group is really gone.
|
|
20233
|
+
*
|
|
20234
|
+
* Fire-and-forget on purpose: the verdict has already been delivered to the user,
|
|
20235
|
+
* and awaiting this would put a descendant that outlived its stop signal back in
|
|
20236
|
+
* the panel's path. The record is cleared only once the group is gone, because
|
|
20237
|
+
* until then it is the evidence that an updater is still writing into the install
|
|
20238
|
+
* root - the thing another launch has to see.
|
|
20239
|
+
*/
|
|
20240
|
+
async holdGlobalInstallGuardUntilGroupGone(groupPid) {
|
|
20241
|
+
let gone = await waitForInstallGroupGone(groupPid, GROUP_EXIT_WAIT_MS);
|
|
20242
|
+
while (!gone) {
|
|
20243
|
+
logger.warn(
|
|
20244
|
+
`The updater's process group ${groupPid} outlived the stop signal and the ${Math.round(GROUP_EXIT_WAIT_MS / 1e3)}s wait; another server update will not be started until it exits`,
|
|
20245
|
+
LogFileType.UPDATE_SERVICE
|
|
20246
|
+
);
|
|
20247
|
+
await new Promise((resolve) => setTimeout(resolve, 5e3));
|
|
20248
|
+
gone = !isInstallGroupAlive(groupPid);
|
|
20249
|
+
}
|
|
20250
|
+
clearPendingServerUpdateMarker(this.markerDir());
|
|
20251
|
+
this.serverUpdateInFlight = false;
|
|
20252
|
+
}
|
|
19792
20253
|
/** The attempt itself, under the guard above. */
|
|
19793
20254
|
async runGlobalUpdateAttempt(backend, plan, target2) {
|
|
19794
20255
|
const consolePath = this.resolveLocalOperatorPath();
|
|
19795
|
-
|
|
20256
|
+
const rebuildPath = resolveCommandPath("lop-update");
|
|
20257
|
+
const running = await this.readRunningBackend();
|
|
20258
|
+
const serving = this.readServingInstall(running);
|
|
20259
|
+
const servingScript = serving.prefix ? this.servingConsoleScript(serving.prefix) : null;
|
|
20260
|
+
const servingIdentity = serving.prefix ? this.servingInstallIdentity(serving.prefix) : null;
|
|
20261
|
+
const installPath = servingScript ?? consolePath;
|
|
20262
|
+
const freshPlan = resolveGlobalInstallPlan({
|
|
20263
|
+
identity: servingIdentity ?? readInstallIdentity(installPath),
|
|
20264
|
+
sourceRebuild: rebuildPath
|
|
20265
|
+
});
|
|
20266
|
+
const rebuildRoute = freshPlan.managedRoute === "source-build" && rebuildPath !== null;
|
|
20267
|
+
if (!consolePath && !rebuildRoute) {
|
|
19796
20268
|
this.sendToRenderer("backend-update-error", {
|
|
19797
20269
|
message: "The global install could not be found on this machine, so the update did not run. See the update service log.",
|
|
19798
20270
|
phase: "update",
|
|
@@ -19800,28 +20272,83 @@ ${heal.removed.join("\n")}`,
|
|
|
19800
20272
|
});
|
|
19801
20273
|
return false;
|
|
19802
20274
|
}
|
|
19803
|
-
const
|
|
19804
|
-
this.
|
|
19805
|
-
|
|
20275
|
+
const budgetMs = rebuildRoute ? SOURCE_REBUILD_TIMEOUT_MS : GLOBAL_UPDATE_TIMEOUT_MS;
|
|
20276
|
+
const before = this.readInstallVersionAt(installPath) ?? plan.installedInstallVersion;
|
|
20277
|
+
const markerBefore = rebuildRoute ? this.readRebuildMarkerState(installPath) : null;
|
|
20278
|
+
this.sendToRenderer("backend-update-progress", {
|
|
20279
|
+
phase: "installing",
|
|
20280
|
+
sourceRebuild: rebuildRoute
|
|
20281
|
+
});
|
|
20282
|
+
const marker = {
|
|
19806
20283
|
before,
|
|
19807
20284
|
target: target2,
|
|
19808
|
-
startedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
19809
|
-
|
|
19810
|
-
|
|
19811
|
-
|
|
19812
|
-
|
|
19813
|
-
|
|
20285
|
+
startedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
20286
|
+
// The record's own budget, so a reader can tell a run that is going from a
|
|
20287
|
+
// record that outlived it without consulting a clock the app chose later.
|
|
20288
|
+
deadlineAt: new Date(Date.now() + budgetMs).toISOString(),
|
|
20289
|
+
groupPid: null,
|
|
20290
|
+
groupStartedAt: null,
|
|
20291
|
+
/*
|
|
20292
|
+
* The install this attempt runs, recorded so a later launch does not have to resolve
|
|
20293
|
+
* one of its own: the reconciliation reads this, and a marker without it (an older
|
|
20294
|
+
* build's) is the only case that falls back.
|
|
20295
|
+
*/
|
|
20296
|
+
installPath
|
|
20297
|
+
};
|
|
20298
|
+
writePendingServerUpdateMarker(this.markerDir(), marker);
|
|
20299
|
+
const run = await this.runGlobalUpdate(
|
|
20300
|
+
rebuildRoute && rebuildPath !== null ? { path: rebuildPath, args: [] } : (
|
|
20301
|
+
/*
|
|
20302
|
+
* THE SERVING INSTALL'S OWN FRONT END when it has one, and the shim's
|
|
20303
|
+
* resolution only as the fallback: the identity above is that script's, so
|
|
20304
|
+
* running anything else would update one install and verify another - the
|
|
20305
|
+
* seam this case classifies (review round 5, Q-1 = M2).
|
|
20306
|
+
*/
|
|
20307
|
+
{ path: installPath ?? "", args: ["update"] }
|
|
20308
|
+
),
|
|
20309
|
+
budgetMs,
|
|
20310
|
+
(pid) => {
|
|
20311
|
+
writePendingServerUpdateMarker(this.markerDir(), {
|
|
20312
|
+
before,
|
|
20313
|
+
target: target2,
|
|
20314
|
+
startedAt: marker.startedAt,
|
|
20315
|
+
deadlineAt: marker.deadlineAt,
|
|
20316
|
+
groupPid: pid,
|
|
20317
|
+
groupStartedAt: readProcessStartStamp(pid),
|
|
20318
|
+
installPath
|
|
20319
|
+
});
|
|
20320
|
+
}
|
|
20321
|
+
);
|
|
20322
|
+
const after = this.readInstallVersionAt(installPath);
|
|
20323
|
+
const markerAfter = rebuildRoute ? this.readRebuildMarkerState(installPath) : null;
|
|
20324
|
+
const groupSurvived = run.timedOut && run.groupStillRunning && run.groupPid !== null;
|
|
20325
|
+
if (!groupSurvived) clearPendingServerUpdateMarker(this.markerDir());
|
|
20326
|
+
const landed = run.exitCode === 0 && (rebuildRoute ? didSourceRebuildLand({
|
|
20327
|
+
before: markerBefore ?? { ref: null, mtimeMs: null },
|
|
20328
|
+
after: markerAfter ?? { ref: null, mtimeMs: null }
|
|
20329
|
+
}) : didUpgradeLand({ before, after, target: target2 }));
|
|
19814
20330
|
if (!landed) {
|
|
19815
|
-
const
|
|
19816
|
-
const
|
|
20331
|
+
const running2 = await this.getInstalledBackendVersion();
|
|
20332
|
+
const diagnosis = installDiagnosisLines(
|
|
20333
|
+
run.stderr.length > 0 ? run.stderr : run.stdout
|
|
20334
|
+
);
|
|
19817
20335
|
logger.error(
|
|
19818
|
-
|
|
20336
|
+
`${rebuildRoute ? "Source rebuild" : "Global install update"} did not land (exited ${run.exitCode}, ran ${run.ran}${groupSurvived ? `, group ${run.groupPid} still alive` : ""}): ${before ?? "unknown"} -> ${after ?? "unknown"}${rebuildRoute ? `, marker ${markerBefore?.ref ?? "none"} -> ${markerAfter?.ref ?? "none"}` : ""}, running backend reports ${running2 ?? "no reading"}`,
|
|
19819
20337
|
LogFileType.UPDATE_SERVICE
|
|
19820
20338
|
);
|
|
19821
20339
|
this.sendToRenderer("backend-update-error", {
|
|
19822
|
-
message:
|
|
19823
|
-
|
|
19824
|
-
|
|
20340
|
+
message: serverUpdateFailureSentence({
|
|
20341
|
+
rebuildRoute,
|
|
20342
|
+
ran: run.ran,
|
|
20343
|
+
exitCode: run.exitCode,
|
|
20344
|
+
groupSurvived,
|
|
20345
|
+
diagnosis,
|
|
20346
|
+
target: target2,
|
|
20347
|
+
after,
|
|
20348
|
+
before,
|
|
20349
|
+
updateCommand: freshPlan.updateCommand ?? null
|
|
20350
|
+
}),
|
|
20351
|
+
installerOutput: diagnosis || void 0,
|
|
19825
20352
|
phase: "update",
|
|
19826
20353
|
logPath: serverUpdateLogPath()
|
|
19827
20354
|
});
|
|
@@ -19833,14 +20360,14 @@ ${tail}` : ""}`,
|
|
|
19833
20360
|
);
|
|
19834
20361
|
if (backend.isUsingExternalBackend()) {
|
|
19835
20362
|
const health = await this.getInstalledBackendVersion();
|
|
19836
|
-
const
|
|
20363
|
+
const running2 = this.eventRunningVersion(health);
|
|
19837
20364
|
logger.info(
|
|
19838
|
-
`Install moved to ${after ?? "unknown"}; the attached backend still reports ${
|
|
20365
|
+
`Install moved to ${after ?? "unknown"}; the attached backend still reports ${running2 ?? "no reading"}${running2 === health ? "" : ` (its own record; /health answers ${health ?? "no reading"})`}`,
|
|
19839
20366
|
LogFileType.UPDATE_SERVICE
|
|
19840
20367
|
);
|
|
19841
20368
|
this.sendToRenderer("backend-update-completed", {
|
|
19842
20369
|
installVersion: after,
|
|
19843
|
-
runningVersion:
|
|
20370
|
+
runningVersion: running2,
|
|
19844
20371
|
restarted: false,
|
|
19845
20372
|
restartable: this.backendIsAppOwned(),
|
|
19846
20373
|
/*
|
|
@@ -19888,7 +20415,14 @@ ${tail}` : ""}`,
|
|
|
19888
20415
|
return false;
|
|
19889
20416
|
}
|
|
19890
20417
|
}
|
|
19891
|
-
const reported = await this.waitForBackendVersion(
|
|
20418
|
+
const reported = await this.waitForBackendVersion(
|
|
20419
|
+
// The rebuild keeps the version, so there is no target to hold it to: the
|
|
20420
|
+
// version the restarted daemon reports is the CHECKOUT's, and holding it to
|
|
20421
|
+
// the release the app offered would fail a restart that landed (the same
|
|
20422
|
+
// reasoning as `sourceBuild` in the prompt). What is held is that the daemon
|
|
20423
|
+
// is back and answering at all.
|
|
20424
|
+
rebuildRoute ? null : target2
|
|
20425
|
+
);
|
|
19892
20426
|
if (reported === null) {
|
|
19893
20427
|
logger.error(
|
|
19894
20428
|
`Backend restarted onto the updated install but did not report ${target2 ?? "the target version"}`,
|
|
@@ -19931,7 +20465,30 @@ ${tail}` : ""}`,
|
|
|
19931
20465
|
async reportUnattendedServerUpdate() {
|
|
19932
20466
|
const marker = readPendingServerUpdateMarker(this.markerDir());
|
|
19933
20467
|
if (!marker) return;
|
|
19934
|
-
const
|
|
20468
|
+
const recordedPid = marker.groupPid ?? null;
|
|
20469
|
+
const recordedAlive = recordedPid !== null && isInstallGroupAlive(recordedPid);
|
|
20470
|
+
const outcome = evaluatePendingServerUpdateMarker({
|
|
20471
|
+
marker,
|
|
20472
|
+
groupAlive: recordedAlive,
|
|
20473
|
+
liveGroupStamp: recordedAlive && recordedPid !== null ? readProcessStartStamp(recordedPid) : null
|
|
20474
|
+
});
|
|
20475
|
+
if (outcome.kind === "running") {
|
|
20476
|
+
logger.warn(
|
|
20477
|
+
`A server update recorded at ${marker.startedAt} is still running (group ${recordedPid}, ${outcome.reason}); leaving its record in place`,
|
|
20478
|
+
LogFileType.UPDATE_SERVICE
|
|
20479
|
+
);
|
|
20480
|
+
return;
|
|
20481
|
+
}
|
|
20482
|
+
const recordPath = marker.installPath ?? null;
|
|
20483
|
+
const after = this.readInstallVersionAt(
|
|
20484
|
+
recordPath ?? this.resolveLocalOperatorPath()
|
|
20485
|
+
);
|
|
20486
|
+
if (recordPath === null) {
|
|
20487
|
+
logger.warn(
|
|
20488
|
+
`The update record from ${marker.startedAt} names no install (written by an older build); reading the shim's resolution instead`,
|
|
20489
|
+
LogFileType.UPDATE_SERVICE
|
|
20490
|
+
);
|
|
20491
|
+
}
|
|
19935
20492
|
clearPendingServerUpdateMarker(this.markerDir());
|
|
19936
20493
|
if (!after || after === marker.before) {
|
|
19937
20494
|
logger.info(
|