agentbox-app 0.1.13 → 0.1.16
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/README.md +24 -9
- package/bin/agentbox.mjs +23 -1
- package/briefs/message-rules.md +36 -8
- package/briefs/worker.md +46 -23
- package/main/account-discovery.mjs +122 -30
- package/main/account-tooling.mjs +29 -1
- package/main/account-usage.mjs +46 -0
- package/main/agent-sessions.mjs +282 -268
- package/main/agents.mjs +179 -466
- package/main/analytics.mjs +12 -2
- package/main/answer-modes.mjs +103 -37
- package/main/approval-prompt-core.mjs +197 -0
- package/main/approval-prompt-server.mjs +4 -138
- package/main/approvals.mjs +101 -297
- package/main/artifact-path.mjs +47 -18
- package/main/claude-input.mjs +48 -4
- package/main/claude-plan.mjs +48 -0
- package/main/claude-session-open.mjs +28 -0
- package/main/claude-usage-process.mjs +40 -0
- package/main/claude-usage.mjs +18 -40
- package/main/cloud/account.mjs +179 -0
- package/main/cloud/aws.mjs +127 -0
- package/main/cloud/browser-return.mjs +58 -0
- package/main/cloud/clients.mjs +15 -0
- package/main/cloud/codex-cloud.mjs +53 -0
- package/main/cloud/config.mjs +107 -0
- package/main/cloud/docs.mjs +219 -0
- package/main/cloud/exec.mjs +27 -0
- package/main/cloud/host.mjs +165 -0
- package/main/cloud/kit.mjs +80 -0
- package/main/cloud/logins.mjs +543 -0
- package/main/cloud/managed.mjs +115 -0
- package/main/cloud/marker.mjs +104 -0
- package/main/cloud/org.mjs +53 -0
- package/main/cloud/reuse.mjs +208 -0
- package/main/cloud/run-cli.mjs +177 -0
- package/main/cloud/run.mjs +1934 -0
- package/main/cloud/usage.mjs +76 -0
- package/main/cloud-permission.mjs +51 -0
- package/main/codex-account.mjs +78 -0
- package/main/codex-app-server.mjs +276 -409
- package/main/codex-approvals.mjs +148 -433
- package/main/codex-models.mjs +20 -2
- package/main/codex-session.mjs +212 -507
- package/main/codex-threads-worker.mjs +12 -0
- package/main/codex-threads.mjs +71 -3
- package/main/codex-usage-file.mjs +5 -3
- package/main/codex-usage.mjs +56 -7
- package/main/codex-watch.mjs +17 -3
- package/main/codex.mjs +95 -363
- package/main/config.mjs +11 -1
- package/main/corner-tag.mjs +340 -19
- package/main/dev-window.mjs +1 -1
- package/main/engine-bin.mjs +232 -282
- package/main/engine-setup.mjs +21 -9
- package/main/feedback.mjs +3 -25
- package/main/folder-pictures.mjs +2 -2
- package/main/freeze-recorder-thread.mjs +86 -0
- package/main/freeze-recorder.mjs +146 -0
- package/main/fresh-user.mjs +20 -9
- package/main/git-change-offthread.mjs +17 -3
- package/main/git-change-thread.mjs +5 -2
- package/main/harnesses/claude.mjs +32 -11
- package/main/harnesses/codex.mjs +9 -2
- package/main/importable-threads.mjs +64 -6
- package/main/ipc.mjs +362 -111
- package/main/leftovers.mjs +117 -25
- package/main/liquid-glass.mjs +26 -0
- package/main/live-replies.mjs +10 -4
- package/main/main-went-red.mjs +330 -0
- package/main/main.mjs +199 -84
- package/main/memory-gate-history.mjs +36 -10
- package/main/memory-gate-server.mjs +62 -0
- package/main/memory-gate.mjs +14 -1
- package/main/menu.mjs +4 -2
- package/main/message-priority.mjs +2 -2
- package/main/mic.mjs +38 -0
- package/main/native/liquid-glass/build/liquid-glass.node +0 -0
- package/main/new-chats-watch.mjs +110 -0
- package/main/new-user-window.mjs +25 -0
- package/main/notify.mjs +24 -8
- package/main/phone-alerts.mjs +117 -0
- package/main/phone-inbox.mjs +625 -0
- package/main/phone-link.mjs +29 -9
- package/main/phone-uploads.mjs +476 -0
- package/main/picture-shape.mjs +114 -0
- package/main/quit-sequence.mjs +143 -0
- package/main/refresh-agent-models.mjs +15 -2
- package/main/relay-client.mjs +69 -1
- package/main/repeats.mjs +10 -4
- package/main/row-label.mjs +12 -41
- package/main/run-change.mjs +13 -0
- package/main/run-ledger.mjs +135 -0
- package/main/save-attachments.mjs +41 -0
- package/main/serve.mjs +45 -3
- package/main/settings.mjs +220 -44
- package/main/shell-path.mjs +185 -37
- package/main/ship-conflict-brief.mjs +119 -0
- package/main/ship-queue.mjs +115 -19
- package/main/source-updater.mjs +18 -10
- package/main/stage-attachment.mjs +68 -0
- package/main/stall-watch.mjs +50 -0
- package/main/store/home.mjs +87 -232
- package/main/store/paths.mjs +11 -0
- package/main/store/teammates-files.mjs +99 -0
- package/main/store/work-items.mjs +231 -13
- package/main/store.mjs +226 -52
- package/main/supervisor.mjs +3103 -341
- package/main/task-folders.mjs +430 -30
- package/main/task-terminals.mjs +110 -34
- package/main/team/index.mjs +114 -10
- package/main/team/memory-cloud.mjs +183 -1
- package/main/team/room-hours.mjs +48 -0
- package/main/team/room-setup.mjs +31 -0
- package/main/team/rooms-memory.mjs +454 -0
- package/main/team/rooms-shape.mjs +42 -0
- package/main/team/rooms-supabase.mjs +324 -0
- package/main/team/rooms.mjs +575 -0
- package/main/team/session.mjs +96 -26
- package/main/team/supabase-backend.mjs +180 -0
- package/main/team/sync.mjs +109 -1
- package/main/teammates.mjs +580 -0
- package/main/terminal-host.mjs +143 -0
- package/main/terminal-hosts.mjs +261 -0
- package/main/transcript-search.mjs +80 -0
- package/main/webview-guard.mjs +45 -0
- package/main/worktree-include.mjs +90 -219
- package/mcp/core/documents.mjs +3 -1
- package/mcp/core/repeats.mjs +88 -0
- package/mcp/core/teammates.mjs +222 -0
- package/mcp/core/work.mjs +76 -4
- package/mcp/stdio.mjs +24 -11
- package/mcp/tools.mjs +116 -8
- package/package.json +21 -5
- package/renderer/dist/assets/{abnfDiagram-YUKMZFIV-DPPuMWt-.js → abnfDiagram-YUKMZFIV-CoWB6YUK.js} +1 -1
- package/renderer/dist/assets/{arc-HmFfdseH.js → arc-Ch0NrCul.js} +1 -1
- package/renderer/dist/assets/{architectureDiagram-47P4ROYG-DxJ3zHrH.js → architectureDiagram-47P4ROYG-CXz9M5tQ.js} +1 -1
- package/renderer/dist/assets/{blockDiagram-E7TT5TSB-CbTXzOqQ.js → blockDiagram-E7TT5TSB-DchGUuHH.js} +1 -1
- package/renderer/dist/assets/{c4Diagram-CRA5TL53-X5ItFyLn.js → c4Diagram-CRA5TL53-Cip-zNyr.js} +1 -1
- package/renderer/dist/assets/channel-BQuQSL--.js +1 -0
- package/renderer/dist/assets/{chunk-2Q5K7J3B-DC12Ho_L.js → chunk-2Q5K7J3B-BoXid_2r.js} +1 -1
- package/renderer/dist/assets/{chunk-5VM5RSS4-ClVtfPd0.js → chunk-5VM5RSS4-Cpez1rRz.js} +1 -1
- package/renderer/dist/assets/{chunk-7TKQ45FW-DgcOm0HT.js → chunk-7TKQ45FW-DKA84YkE.js} +1 -1
- package/renderer/dist/assets/{chunk-AW2ZBBNX-C01s4yoF.js → chunk-AW2ZBBNX-Dg-SumPG.js} +1 -1
- package/renderer/dist/assets/{chunk-FVRAUYC3-Bj0zxhR4.js → chunk-FVRAUYC3-06TCvPm1.js} +2 -2
- package/renderer/dist/assets/{chunk-HTAEGDNF-DWDeEhLU.js → chunk-HTAEGDNF-CMEBy1Td.js} +1 -1
- package/renderer/dist/assets/{chunk-JWPE2WC7-Dsqwv3eW.js → chunk-JWPE2WC7-BJ2ObmV-.js} +1 -1
- package/renderer/dist/assets/{chunk-KQW6MTUR-CE86ANL2.js → chunk-KQW6MTUR-CGWOYXUE.js} +1 -1
- package/renderer/dist/assets/{chunk-XXDRQBXY-CoNTASfs.js → chunk-XXDRQBXY-CiZp07XC.js} +1 -1
- package/renderer/dist/assets/{classDiagram-v2-K4WV4PDN-2wprLqtw.js → classDiagram-v2-K4WV4PDN-DJNIX719.js} +1 -1
- package/renderer/dist/assets/cornerTag-CPgvu-q3.js +1 -0
- package/renderer/dist/assets/cornerTag-oMfEjzCO.css +1 -0
- package/renderer/dist/assets/{cose-bilkent-JH36ORCC-enqMiYbn.js → cose-bilkent-JH36ORCC-CbkyZOUH.js} +1 -1
- package/renderer/dist/assets/{cynefin-EF2NZ3EQ-DHngLxvQ.js → cynefin-EF2NZ3EQ-78iOroZO.js} +1 -1
- package/renderer/dist/assets/{cynefinDiagram-3GCD6N5R-C0rZZIl3.js → cynefinDiagram-3GCD6N5R-GcCopMs7.js} +1 -1
- package/renderer/dist/assets/{dagre-W4DXFKR2-DQnaEWO7.js → dagre-W4DXFKR2-DHLlXLkz.js} +1 -1
- package/renderer/dist/assets/{diagram-2UJZ2QOL-CS8AlGPx.js → diagram-2UJZ2QOL-DuZSUHcH.js} +1 -1
- package/renderer/dist/assets/{diagram-OVF4WLC6-yiDMHIkY.js → diagram-OVF4WLC6-Ckxu3cqH.js} +1 -1
- package/renderer/dist/assets/{diagram-PFPMY2P6-XbQKzbBA.js → diagram-PFPMY2P6-CsTw9Ycn.js} +1 -1
- package/renderer/dist/assets/{diagram-UMYRVEAY-dhVvlfl_.js → diagram-UMYRVEAY-CTXd8qVg.js} +1 -1
- package/renderer/dist/assets/{diagram-YEKJPTXX-DZHe8SCf.js → diagram-YEKJPTXX-CJs1T_9m.js} +1 -1
- package/renderer/dist/assets/{diagram-ZIFT7M5P-B5h6aXVR.js → diagram-ZIFT7M5P-CvjI23mf.js} +1 -1
- package/renderer/dist/assets/{ebnfDiagram-VR2GEFS7-PR9ScDco.js → ebnfDiagram-VR2GEFS7-d_IeAzOJ.js} +1 -1
- package/renderer/dist/assets/{elk-IJKZMXRS-Cg-yQXPd.js → elk-IJKZMXRS-zo9KFP3N.js} +1 -1
- package/renderer/dist/assets/{erDiagram-O2IAPWRE-omOrJMxu.js → erDiagram-O2IAPWRE-DqfYf5Dt.js} +1 -1
- package/renderer/dist/assets/{flowDiagram-OXPTDLAJ-aOX2hzvT.js → flowDiagram-OXPTDLAJ-DAWsMLmo.js} +1 -1
- package/renderer/dist/assets/{ganttDiagram-R7TSEDQI-B1H3pWyI.js → ganttDiagram-R7TSEDQI-4ml_bhwR.js} +1 -1
- package/renderer/dist/assets/{gitGraphDiagram-XJZIOB7I-Cvw304HC.js → gitGraphDiagram-XJZIOB7I-B_WcqSId.js} +1 -1
- package/renderer/dist/assets/{infoDiagram-5W2HQ5XZ-_ZXcNViH.js → infoDiagram-5W2HQ5XZ-CI-jYqzc.js} +1 -1
- package/renderer/dist/assets/{ishikawaDiagram-K3B6WC7H-C7Mx_qQk.js → ishikawaDiagram-K3B6WC7H-B56VWRlV.js} +1 -1
- package/renderer/dist/assets/{journeyDiagram-COXZFDF6-fr62QoAt.js → journeyDiagram-COXZFDF6-D1-p09Lr.js} +1 -1
- package/renderer/dist/assets/{kanban-definition-P3RFRI5V-J8zsabCe.js → kanban-definition-P3RFRI5V-Cv3w7JoN.js} +1 -1
- package/renderer/dist/assets/{linear-BmNdEtIi.js → linear-C35V2Spi.js} +1 -1
- package/renderer/dist/assets/main-BcoBiGwv.css +32 -0
- package/renderer/dist/assets/main-CLnIOulD.js +1191 -0
- package/renderer/dist/assets/{mermaid.core-CnYzvFkz.js → mermaid.core-BzGT5Vo9.js} +4 -4
- package/renderer/dist/assets/{mindmap-definition-LZFPQGKD-Bfcxc7PM.js → mindmap-definition-LZFPQGKD-BZUfuymC.js} +1 -1
- package/renderer/dist/assets/{pegDiagram-BGZESJAR-CwUJOq6X.js → pegDiagram-BGZESJAR-DFh9XNGe.js} +1 -1
- package/renderer/dist/assets/{pieDiagram-CAPJLFHJ-BrwMmqfC.js → pieDiagram-CAPJLFHJ-C65tPVsy.js} +1 -1
- package/renderer/dist/assets/{product-name-CTKLYNWY.js → product-name-CSNW6d7s.js} +1 -1
- package/renderer/dist/assets/{quadrantDiagram-GDMTTRAM-BN4Stoj-.js → quadrantDiagram-GDMTTRAM-Cushj_nk.js} +1 -1
- package/renderer/dist/assets/{railroadDiagram-SM67HX2B-CPZ5bbeR.js → railroadDiagram-SM67HX2B-DZGebGx5.js} +1 -1
- package/renderer/dist/assets/{requirementDiagram-X7JNWC4A-CgRnimVe.js → requirementDiagram-X7JNWC4A-DpAB0MNo.js} +1 -1
- package/renderer/dist/assets/{sankeyDiagram-UM26HJRW-Bt2rej__.js → sankeyDiagram-UM26HJRW-D-29rHra.js} +1 -1
- package/renderer/dist/assets/{sequenceDiagram-ZO4K6R2Y-Dd5nKe9G.js → sequenceDiagram-ZO4K6R2Y-FfOQAZol.js} +1 -1
- package/renderer/dist/assets/{sizeCapture-INFHLROL-usyNyXyh.js → sizeCapture-INFHLROL-C_n6fDBY.js} +1 -1
- package/renderer/dist/assets/{stateDiagram-v2-TBUQTH76-CRzUBb0u.js → stateDiagram-v2-TBUQTH76-BZjenzJG.js} +1 -1
- package/renderer/dist/assets/{swimlanes-N4OXWK64-C1GJvMS0.js → swimlanes-N4OXWK64-DxaMU9vX.js} +1 -1
- package/renderer/dist/assets/swimlanesDiagram-K3J5GTZL-Cb2kM6rr.js +8 -0
- package/renderer/dist/assets/{timeline-definition-YOQAKGHF-caeyPlcc.js → timeline-definition-YOQAKGHF-DTg5HJKz.js} +1 -1
- package/renderer/dist/assets/{usecaseDiagram-VIAY4XPW-BN_SV6PC.js → usecaseDiagram-VIAY4XPW-Bc3MZDsl.js} +1 -1
- package/renderer/dist/assets/{vennDiagram-BLWOH2XV-BLGuaUX-.js → vennDiagram-BLWOH2XV-GIVGk9Id.js} +1 -1
- package/renderer/dist/assets/{wardleyDiagram-YMQ3BMBF-Da0cJeNc.js → wardleyDiagram-YMQ3BMBF-NmJnbQOg.js} +1 -1
- package/renderer/dist/assets/{xychartDiagram-TAQBALBS-cGfUffMk.js → xychartDiagram-TAQBALBS-p-kfGfnW.js} +1 -1
- package/renderer/dist/corner-tag.html +3 -3
- package/renderer/dist/index.html +3 -3
- package/scripts/after-install.mjs +30 -8
- package/scripts/cloud-shim.pl +57 -0
- package/shared/account-names.d.mts +6 -0
- package/shared/account-names.mjs +15 -0
- package/shared/agent-hours.mjs +136 -0
- package/shared/agent-import.mjs +60 -34
- package/shared/agents.mjs +126 -522
- package/shared/analytics-events.mjs +12 -0
- package/shared/approval-card.mjs +36 -0
- package/shared/bridge-map.mjs +27 -1
- package/shared/claude-code-shape.mjs +6 -0
- package/shared/claude-usage.d.mts +1 -0
- package/shared/claude-usage.mjs +42 -1
- package/shared/cloud-hand-back.d.mts +7 -0
- package/shared/cloud-hand-back.mjs +20 -0
- package/shared/cloud-kit.d.mts +219 -0
- package/shared/cloud-kit.mjs +341 -0
- package/shared/cloud-login-name.mjs +4 -0
- package/shared/cloud-start.mjs +17 -0
- package/shared/cloud-versions.mjs +23 -0
- package/shared/codex-import.mjs +2 -2
- package/shared/codex-modes.d.mts +22 -9
- package/shared/codex-modes.mjs +67 -11
- package/shared/codex-usage.mjs +108 -2
- package/shared/corner-tag.d.mts +23 -1
- package/shared/corner-tag.mjs +284 -29
- package/shared/dead-run-trace.mjs +19 -2
- package/shared/engines.mjs +70 -309
- package/shared/feedback.d.mts +7 -0
- package/shared/feedback.mjs +25 -0
- package/shared/first-run-practice.mjs +17 -24
- package/shared/harness-definitions.mjs +8 -0
- package/shared/instruction-defaults.json +2 -2
- package/shared/key-caps.d.mts +14 -0
- package/shared/key-caps.mjs +68 -0
- package/shared/message-edits.d.mts +7 -0
- package/shared/message-edits.mjs +35 -0
- package/shared/move-thread.d.mts +24 -0
- package/shared/move-thread.mjs +112 -0
- package/shared/named-threads.mjs +72 -0
- package/shared/not-shipped.d.mts +22 -0
- package/shared/not-shipped.mjs +89 -0
- package/shared/notify-rules.d.mts +6 -0
- package/shared/notify-rules.mjs +36 -8
- package/shared/phone-files.mjs +28 -0
- package/shared/phone-settings.mjs +118 -0
- package/shared/phone-snapshot.d.mts +16 -0
- package/shared/phone-snapshot.mjs +348 -0
- package/shared/picture-formats.d.mts +1 -0
- package/shared/picture-formats.mjs +13 -0
- package/shared/rank.d.mts +2 -1
- package/shared/rank.mjs +32 -19
- package/shared/repeats.d.mts +8 -0
- package/shared/repeats.mjs +33 -2
- package/shared/room-cards.mjs +167 -0
- package/shared/room-links.d.mts +4 -0
- package/shared/room-links.mjs +21 -0
- package/shared/runs-on.d.mts +3 -3
- package/shared/runs-on.mjs +8 -5
- package/shared/shipped-note.d.mts +4 -0
- package/shared/shipped-note.mjs +6 -0
- package/shared/shortcuts.mjs +14 -6
- package/shared/spawn-trouble.d.mts +4 -0
- package/shared/spawn-trouble.mjs +443 -40
- package/shared/team-rules.mjs +9 -2
- package/shared/teammates.d.mts +19 -0
- package/shared/teammates.mjs +756 -0
- package/shared/terminal-focus.d.mts +1 -0
- package/shared/terminal-focus.mjs +13 -0
- package/shared/thread-cards.mjs +4 -1
- package/shared/usage.d.mts +9 -0
- package/shared/usage.mjs +49 -3
- package/shared/websites.d.mts +15 -0
- package/shared/websites.mjs +61 -0
- package/shared/work-items.mjs +127 -30
- package/shared/work-lines.mjs +92 -244
- package/renderer/dist/assets/channel-BlT2g9Sz.js +0 -1
- package/renderer/dist/assets/cornerTag-BLrxiDE5.css +0 -1
- package/renderer/dist/assets/cornerTag-BOT6u3Tn.js +0 -1
- package/renderer/dist/assets/main-ChNdnUvt.js +0 -1132
- package/renderer/dist/assets/main-nj0QR5kg.css +0 -32
- package/renderer/dist/assets/swimlanesDiagram-K3J5GTZL-Cy4Av2_p.js +0 -8
package/main/leftovers.mjs
CHANGED
|
@@ -26,9 +26,11 @@
|
|
|
26
26
|
// leftovers and no record at all is recorded as ended when this app, whose
|
|
27
27
|
// store it is in, has no session for it; the clock starts then, never
|
|
28
28
|
// back-dated.
|
|
29
|
-
// 4. ITS PROGRAMS STOP `
|
|
30
|
-
//
|
|
31
|
-
//
|
|
29
|
+
// 4. ITS PROGRAMS STOP `closeGraceMs` (2 min) AFTER THE TASK IS CLOSED, so a
|
|
30
|
+
// Z that reopens it keeps them. While memory is tight or critical, also
|
|
31
|
+
// `tightIdleMs` (10 min) after the run ended, one task per look, the one
|
|
32
|
+
// whose run ended longest ago first. Never sooner than that long after
|
|
33
|
+
// the switch went on.
|
|
32
34
|
// 5. NOTHING PROTECTED IS EVER STOPPED: AGENTBOX_KEEP=1 in its environment,
|
|
33
35
|
// an executable inside an installed app or the system (Docker Desktop and
|
|
34
36
|
// its VM, a browser an agent opened), an executable that cannot be read,
|
|
@@ -49,10 +51,10 @@
|
|
|
49
51
|
// (main/supervisor.mjs) and everything it starts inherits it; all 40 leftovers
|
|
50
52
|
// that morning carried it, Docker's backend included. The id is read only from
|
|
51
53
|
// the ENVIRONMENT, which is the part of `ps -E` after the arguments `ps` prints
|
|
52
|
-
// without it, and only when both looks agree it is the same process;
|
|
53
|
-
// command's own words counts for
|
|
54
|
-
// (next-server) hides its environment
|
|
55
|
-
// to the same task. Codex threads share one app-server and their commands carry
|
|
54
|
+
// without it, and only when both looks agree it is the same process;
|
|
55
|
+
// /proc/<pid>/environ on Linux. A tag in a command's own words counts for
|
|
56
|
+
// nothing. A program that renames itself (next-server) hides its environment
|
|
57
|
+
// from `ps`, so a process under a tagged one belongs to the same task. Codex threads share one app-server and their commands carry
|
|
56
58
|
// no task id yet, so a Codex task's leftovers are not found: coverage is
|
|
57
59
|
// incomplete and the switch says so.
|
|
58
60
|
|
|
@@ -151,8 +153,69 @@ export function applyExecutables(procs, lsofText) {
|
|
|
151
153
|
return procs;
|
|
152
154
|
}
|
|
153
155
|
|
|
154
|
-
/**
|
|
155
|
-
|
|
156
|
+
/** /proc counts in USER_HZ, which the kernel fixes at 100 whatever its own tick is. */
|
|
157
|
+
const USER_HZ = 100;
|
|
158
|
+
|
|
159
|
+
/** When this computer booted, which /proc/<pid>/stat counts a start time from. */
|
|
160
|
+
function bootSeconds(root) {
|
|
161
|
+
const stat = fs.readFileSync(path.join(root, 'stat'), 'utf8').match(/^btime (\d+)$/m);
|
|
162
|
+
if (stat) return +stat[1];
|
|
163
|
+
const up = Number(String(fs.readFileSync(path.join(root, 'uptime'), 'utf8')).split(/\s+/)[0]);
|
|
164
|
+
return Math.floor(Date.now() / 1000 - up);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* The same record the Mac's three `ps` listings give, read from /proc, because
|
|
169
|
+
* `-E` is BSD's and Linux's procps refuses it (w-bd1806285e).
|
|
170
|
+
*
|
|
171
|
+
* `exe` is the real executable even for a program that renamed itself, so
|
|
172
|
+
* nothing here needs lsof. A process that ends mid-read is left out; one
|
|
173
|
+
* belonging to somebody else keeps an empty executable and no environment,
|
|
174
|
+
* which is what leaves it protected.
|
|
175
|
+
*/
|
|
176
|
+
export function readLinuxProcesses({ root = '/proc', hz = USER_HZ, btime = null } = {}) {
|
|
177
|
+
const boot = btime ?? bootSeconds(root);
|
|
178
|
+
const out = [];
|
|
179
|
+
for (const name of fs.readdirSync(root)) {
|
|
180
|
+
if (!/^\d+$/.test(name)) continue;
|
|
181
|
+
const dir = path.join(root, name);
|
|
182
|
+
let stat;
|
|
183
|
+
try { stat = fs.readFileSync(path.join(dir, 'stat'), 'utf8'); } catch { continue; }
|
|
184
|
+
const close = stat.lastIndexOf(')');
|
|
185
|
+
if (close < 0) continue;
|
|
186
|
+
const comm = stat.slice(stat.indexOf('(') + 1, close);
|
|
187
|
+
// The fields after the command, which is the only one that can hold spaces
|
|
188
|
+
// and brackets: state, the parent, and on to the start time.
|
|
189
|
+
const rest = stat.slice(close + 2).split(' ');
|
|
190
|
+
const read = (f) => { try { return fs.readFileSync(path.join(dir, f), 'utf8'); } catch { return null; } };
|
|
191
|
+
const words = (text) => String(text ?? '').split('\0').filter(Boolean);
|
|
192
|
+
const env = new Map(words(read('environ')).map((s) => {
|
|
193
|
+
const eq = s.indexOf('=');
|
|
194
|
+
return eq < 0 ? [s, ''] : [s.slice(0, eq), s.slice(eq + 1)];
|
|
195
|
+
}));
|
|
196
|
+
const argv = words(read('cmdline')).join(' ');
|
|
197
|
+
const rss = (read('status') ?? '').match(/^VmRSS:\s+(\d+) kB$/m);
|
|
198
|
+
let exe = '';
|
|
199
|
+
try { exe = fs.readlinkSync(path.join(dir, 'exe')).replace(/ \(deleted\)$/, ''); } catch {}
|
|
200
|
+
const item = env.get('ZERO_ITEM');
|
|
201
|
+
out.push({
|
|
202
|
+
pid: +name,
|
|
203
|
+
ppid: +rest[1],
|
|
204
|
+
mb: rss ? +rss[1] / 1024 : 0,
|
|
205
|
+
start: boot + Math.floor(+rest[19] / hz),
|
|
206
|
+
exe,
|
|
207
|
+
cmd: (argv || `[${comm}]`).slice(0, 300),
|
|
208
|
+
item: /^w-[0-9a-f]+$/.test(item ?? '') ? item : null,
|
|
209
|
+
product: env.get('ZERO_PRODUCT')?.split(/\s/)[0] || null,
|
|
210
|
+
keep: env.get('AGENTBOX_KEEP') === '1',
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
return out;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** Every process, tagged with its task where it can be: `ps` on a Mac (about 100 ms), /proc on Linux. */
|
|
217
|
+
export async function listProcesses(platform = process.platform) {
|
|
218
|
+
if (platform !== 'darwin') return readLinuxProcesses();
|
|
156
219
|
const [base, argv, full] = await Promise.all([
|
|
157
220
|
ps(['-axo', 'pid=,ppid=,rss=,lstart=,comm=']),
|
|
158
221
|
ps(['-ww', '-axo', 'pid=,lstart=,command=']),
|
|
@@ -170,7 +233,7 @@ export async function listProcesses() {
|
|
|
170
233
|
return procs;
|
|
171
234
|
}
|
|
172
235
|
|
|
173
|
-
async function readMacPressureWord() {
|
|
236
|
+
export async function readMacPressureWord() {
|
|
174
237
|
const out = await new Promise((resolve) => {
|
|
175
238
|
execFile('/usr/sbin/sysctl', ['-n', 'kern.memorystatus_vm_pressure_level', 'kern.memorystatus_level'], { timeout: 5_000 },
|
|
176
239
|
(err, stdout) => resolve(err ? '' : stdout));
|
|
@@ -215,7 +278,9 @@ export class LeftoverCleaner {
|
|
|
215
278
|
enabledSince = Date.now(),
|
|
216
279
|
live = () => false,
|
|
217
280
|
owns = () => false,
|
|
218
|
-
|
|
281
|
+
// When the task was closed, or null while it is open.
|
|
282
|
+
closedAt = () => null,
|
|
283
|
+
closeGraceMs = 2 * 60_000,
|
|
219
284
|
tightIdleMs = 10 * 60_000,
|
|
220
285
|
confirmMs = 60_000,
|
|
221
286
|
settleMs = 30_000,
|
|
@@ -225,8 +290,9 @@ export class LeftoverCleaner {
|
|
|
225
290
|
enabled = true,
|
|
226
291
|
log = () => {},
|
|
227
292
|
} = {}) {
|
|
228
|
-
Object.assign(this, { list, kill, clock, readPressure, file, enabledSince, live, owns,
|
|
293
|
+
Object.assign(this, { list, kill, clock, readPressure, file, enabledSince, live, owns, closedAt, closeGraceMs, tightIdleMs, confirmMs, settleMs, home, enabled, log });
|
|
229
294
|
this.firstSeen = new Map();
|
|
295
|
+
this.withPrograms = new Set();
|
|
230
296
|
this.lastStatus = { leftovers: [], survivors: [], stopped: null, pressure: 'normal' };
|
|
231
297
|
const saved = this._load();
|
|
232
298
|
this.runs = saved.runs;
|
|
@@ -274,6 +340,9 @@ export class LeftoverCleaner {
|
|
|
274
340
|
|
|
275
341
|
status() { return this.lastStatus; }
|
|
276
342
|
|
|
343
|
+
/** Whether the last look found programs of this task's that could be stopped. */
|
|
344
|
+
hasPrograms(item) { return this.withPrograms.has(item); }
|
|
345
|
+
|
|
277
346
|
/** Stop signalling from this moment, even in a look already under way. */
|
|
278
347
|
disable() { this.enabled = false; }
|
|
279
348
|
|
|
@@ -308,6 +377,7 @@ export class LeftoverCleaner {
|
|
|
308
377
|
return this.lastStatus;
|
|
309
378
|
}
|
|
310
379
|
const view = this._view(procs, now);
|
|
380
|
+
this.withPrograms = new Set([...view.tasks.values()].filter((t) => t.procs.length).map((t) => t.item));
|
|
311
381
|
this._reconcileRuns(view, now);
|
|
312
382
|
this._trackStopping(view, now);
|
|
313
383
|
const due = this._due(view, now, pressure);
|
|
@@ -407,31 +477,53 @@ export class LeftoverCleaner {
|
|
|
407
477
|
}
|
|
408
478
|
}
|
|
409
479
|
|
|
410
|
-
|
|
480
|
+
/** When this task's programs stop, or null while nothing says they should. */
|
|
481
|
+
_stopsAt(item, product, pressure) {
|
|
411
482
|
const run = this.runs[item];
|
|
412
483
|
if (!run || run.state !== 'ended') return null;
|
|
413
484
|
const from = Math.max(run.endedAt, this.enabledSince);
|
|
414
|
-
|
|
485
|
+
let closed = null;
|
|
486
|
+
try { closed = this.closedAt(item, product); } catch { closed = null; }
|
|
487
|
+
const times = [];
|
|
488
|
+
if (Number.isFinite(closed)) times.push(Math.max(from, closed) + this.closeGraceMs);
|
|
489
|
+
if (pressure !== 'normal') times.push(from + this.tightIdleMs);
|
|
490
|
+
return times.length ? Math.min(...times) : null;
|
|
415
491
|
}
|
|
416
492
|
|
|
417
|
-
/**
|
|
493
|
+
/**
|
|
494
|
+
* Programs whose time has come and that have been seen long enough: every
|
|
495
|
+
* closed task's, and while memory is short, one more task's, the one whose
|
|
496
|
+
* run ended longest ago.
|
|
497
|
+
*/
|
|
418
498
|
_due(view, now, pressure) {
|
|
419
499
|
const due = [];
|
|
420
500
|
if (!this.enabled) return due;
|
|
501
|
+
let short = null;
|
|
421
502
|
for (const t of view.tasks.values()) {
|
|
422
503
|
if (this._alive(t)) continue;
|
|
423
|
-
const
|
|
504
|
+
const closed = this._stopsAt(t.item, t.product, 'normal');
|
|
505
|
+
if (closed !== null && now >= closed) { due.push(...this._ready(t, now)); continue; }
|
|
506
|
+
const at = pressure === 'normal' ? null : this._stopsAt(t.item, t.product, pressure);
|
|
424
507
|
if (at === null || now < at) continue;
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
if (now - (this.firstSeen.get(id) ?? now) < this.confirmMs) continue;
|
|
429
|
-
due.push({ item: t.item, id, pid: p.pid });
|
|
430
|
-
}
|
|
508
|
+
const ready = this._ready(t, now);
|
|
509
|
+
const ended = this.runs[t.item].endedAt;
|
|
510
|
+
if (ready.length && (!short || ended < short.ended)) short = { ended, ready };
|
|
431
511
|
}
|
|
512
|
+
if (short) due.push(...short.ready);
|
|
432
513
|
return due;
|
|
433
514
|
}
|
|
434
515
|
|
|
516
|
+
_ready(t, now) {
|
|
517
|
+
const out = [];
|
|
518
|
+
for (const p of t.procs) {
|
|
519
|
+
const id = idOf(p);
|
|
520
|
+
if (this.stopping[id]) continue;
|
|
521
|
+
if (now - (this.firstSeen.get(id) ?? now) < this.confirmMs) continue;
|
|
522
|
+
out.push({ item: t.item, id, pid: p.pid });
|
|
523
|
+
}
|
|
524
|
+
return out;
|
|
525
|
+
}
|
|
526
|
+
|
|
435
527
|
/** One fresh look, every rule again, then SIGTERM to what still passes. */
|
|
436
528
|
async _stop(due, pressure) {
|
|
437
529
|
let procs;
|
|
@@ -448,8 +540,8 @@ export class LeftoverCleaner {
|
|
|
448
540
|
const t = view.tasks.get(d.item);
|
|
449
541
|
if (!t || !t.procs.includes(p)) continue; // protected now, or no longer this task's
|
|
450
542
|
if (this._alive(t)) continue;
|
|
451
|
-
const at = this._stopsAt(d.item, pressure);
|
|
452
|
-
if (at === null || now < at) continue; // a run started meanwhile
|
|
543
|
+
const at = this._stopsAt(d.item, t.product, pressure);
|
|
544
|
+
if (at === null || now < at) continue; // a run started, or the task reopened, meanwhile
|
|
453
545
|
// Switched off while this look was under way: nothing more is signalled.
|
|
454
546
|
if (!this.enabled) break;
|
|
455
547
|
try { this.kill(p.pid, 'SIGTERM'); } catch { continue; }
|
|
@@ -477,7 +569,7 @@ export class LeftoverCleaner {
|
|
|
477
569
|
kept: t.guarded.length,
|
|
478
570
|
mb: all.reduce((n, p) => n + (p.mb ?? 0), 0),
|
|
479
571
|
oldest: Math.min(...all.map((p) => p.start * 1000)),
|
|
480
|
-
stopsAt: t.procs.length ? this._stopsAt(t.item, pressure) : null,
|
|
572
|
+
stopsAt: t.procs.length ? this._stopsAt(t.item, t.product, pressure) : null,
|
|
481
573
|
});
|
|
482
574
|
}
|
|
483
575
|
return out.sort((a, b) => a.oldest - b.oldest);
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// The Mac's own Liquid Glass for a window, or null where there is none: not a
|
|
2
|
+
// Mac, a macOS before 26, or a build that could not compile main/native
|
|
3
|
+
// (scripts/build-liquid-glass.mjs). Null means the page draws its own glass.
|
|
4
|
+
import { createRequire } from 'node:module';
|
|
5
|
+
|
|
6
|
+
export function loadLiquidGlass({ mac = process.platform === 'darwin', load = createRequire(import.meta.url) } = {}) {
|
|
7
|
+
if (!mac) return null;
|
|
8
|
+
let native;
|
|
9
|
+
try {
|
|
10
|
+
native = load('./native/liquid-glass/build/liquid-glass.node');
|
|
11
|
+
if (!native.available()) return null;
|
|
12
|
+
} catch {
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
const handle = (win) => win.getNativeWindowHandle();
|
|
16
|
+
return {
|
|
17
|
+
// tone: 'light' or 'dark' holds the glass that way; leave it out and the
|
|
18
|
+
// glass decides from what is behind it. style: 'clear' for the Mac's clear
|
|
19
|
+
// glass, which lets a light page through; otherwise the regular glass.
|
|
20
|
+
set: (win, rect, { radius, tone, style } = {}) => native.set(handle(win), rect, {
|
|
21
|
+
radius, ...(tone ? { tone } : {}), ...(style ? { style } : {}),
|
|
22
|
+
}),
|
|
23
|
+
clear: (win) => native.clear(handle(win)),
|
|
24
|
+
tone: (win) => native.tone(handle(win)),
|
|
25
|
+
};
|
|
26
|
+
}
|
package/main/live-replies.mjs
CHANGED
|
@@ -15,9 +15,11 @@ import { TURN_CLOSED } from './claude-input.mjs';
|
|
|
15
15
|
// is the result, so without this it has every reason to save the answer for an
|
|
16
16
|
// ending the person is not waiting through.
|
|
17
17
|
//
|
|
18
|
-
// Their words go LAST
|
|
19
|
-
//
|
|
20
|
-
|
|
18
|
+
// Their words go LAST: the newest thing they said is the newest thing in its
|
|
19
|
+
// context, and nothing of ours is written after it. `files` is the supervisor's
|
|
20
|
+
// `attachmentBlock` for this reply: the pictures in it by absolute path, which
|
|
21
|
+
// is also what a cloud run reads to send them up (main/cloud/run.mjs).
|
|
22
|
+
export function liveReplyBrief(answer, files = '') {
|
|
21
23
|
return [
|
|
22
24
|
'This arrived in your thread just now, while you work, and they are watching',
|
|
23
25
|
'for a reply. Do what it asks. If it asks you anything, or says it did not',
|
|
@@ -25,6 +27,7 @@ export function liveReplyBrief(answer) {
|
|
|
25
27
|
'you go back to what you were doing: the sentences you write while you work',
|
|
26
28
|
'are what they read, so an answer saved for the end reads as no answer at all.',
|
|
27
29
|
'Then carry on.',
|
|
30
|
+
...(files ? [files] : []),
|
|
28
31
|
'',
|
|
29
32
|
'What they said:',
|
|
30
33
|
'',
|
|
@@ -49,7 +52,10 @@ export function submitReply(supervisor, payload, commit) {
|
|
|
49
52
|
// A held Remote Control conversation is sitting idle rather than working,
|
|
50
53
|
// and the slash commands it takes stop being commands once prose is wrapped
|
|
51
54
|
// round them. Both reach it exactly as typed.
|
|
52
|
-
const
|
|
55
|
+
const files = session.remoteIdle ? null : supervisor.replyFiles?.(product, answer, session.engine, id);
|
|
56
|
+
const taken = session.remoteIdle
|
|
57
|
+
? session.child.steer(answer)
|
|
58
|
+
: session.child.steer(liveReplyBrief(answer, files?.block), files?.pictures ?? []);
|
|
53
59
|
// SENT NOW: her words go in line first, then the step is cut, in the same
|
|
54
60
|
// tick, so the cut turn's result can never close the input ahead of them.
|
|
55
61
|
// A cut that fails leaves the message waiting its turn, which is still sent.
|
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
// MAIN GOES RED ON GITHUB AND ONE TASK SAYS WHICH TESTS (w-3836800002).
|
|
2
|
+
//
|
|
3
|
+
// Main was red on GitHub from 2026-10-09 to 2026-10-13 and nobody noticed. The
|
|
4
|
+
// `tests` workflow takes about five minutes, which is after the agent that
|
|
5
|
+
// shipped has already ended its turn, and `scripts/ship.mjs` signs off by
|
|
6
|
+
// printing "check the run" to a terminal nobody is reading. So the one gate
|
|
7
|
+
// that runs the WHOLE suite on both machines had no reader at all.
|
|
8
|
+
//
|
|
9
|
+
// The app ships the branch (main/ship-queue.mjs), so the app is the thing still
|
|
10
|
+
// running when that cross appears. After every successful ship it waits for
|
|
11
|
+
// that commit's run and files ONE task when main turns red: which tests, which
|
|
12
|
+
// machine each failed on, and the commit that did it.
|
|
13
|
+
//
|
|
14
|
+
// NOTHING BLOCKS ON THIS. No ship waits for it, no agent is held, and every
|
|
15
|
+
// failure here is silent: a machine without `gh`, or signed out of it, or
|
|
16
|
+
// without access to the repository, does nothing at all. A watcher that
|
|
17
|
+
// interrupted shipping to report on shipping would be worse than the gap it
|
|
18
|
+
// closes.
|
|
19
|
+
//
|
|
20
|
+
// ONE BREAKAGE IS ONE ROW. While main stays red, further ships file nothing:
|
|
21
|
+
// a row per red commit would be a dozen rows about one mistake. The green
|
|
22
|
+
// again lands on that same row as a note, so the row that told them reads as
|
|
23
|
+
// finished where they are already looking.
|
|
24
|
+
|
|
25
|
+
import { execFile } from 'node:child_process';
|
|
26
|
+
import fs from 'node:fs';
|
|
27
|
+
import path from 'node:path';
|
|
28
|
+
|
|
29
|
+
/** How often to ask GitHub, and how long before giving up on a run. */
|
|
30
|
+
export const POLL_MS = 60 * 1000;
|
|
31
|
+
export const GIVE_UP_MS = 30 * 60 * 1000;
|
|
32
|
+
|
|
33
|
+
const STATE = 'main-tests.json';
|
|
34
|
+
const WORKFLOW = 'tests';
|
|
35
|
+
const LOG_BYTES = 16 * 1024 * 1024;
|
|
36
|
+
|
|
37
|
+
/** `gh`, with its own words back and never a throw. */
|
|
38
|
+
export function runGh(args, { cwd, timeout = 60 * 1000 } = {}) {
|
|
39
|
+
return new Promise((resolve) => {
|
|
40
|
+
execFile('gh', args, { cwd, encoding: 'utf8', timeout, maxBuffer: LOG_BYTES }, (error, stdout, stderr) => {
|
|
41
|
+
if (error) resolve({ code: error.code ?? 1, out: `${stdout ?? ''}${stderr ?? ''}${error.message ?? ''}` });
|
|
42
|
+
else resolve({ code: 0, out: String(stdout ?? '') });
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The run for the commit that was just shipped, or null while GitHub has not
|
|
49
|
+
* made one yet.
|
|
50
|
+
*
|
|
51
|
+
* `scripts/ship.mjs` reports the SHORT sha and GitHub answers with the full
|
|
52
|
+
* forty, so either may be the prefix of the other.
|
|
53
|
+
*/
|
|
54
|
+
export function runFor(runs, sha) {
|
|
55
|
+
const want = String(sha ?? '').trim().toLowerCase();
|
|
56
|
+
if (!want) return null;
|
|
57
|
+
return (runs ?? []).find((run) => {
|
|
58
|
+
const head = String(run?.headSha ?? '').toLowerCase();
|
|
59
|
+
return head && (head.startsWith(want) || want.startsWith(head));
|
|
60
|
+
}) ?? null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* What that run says: `waiting`, `passing`, `failing`, or `ignore`.
|
|
65
|
+
*
|
|
66
|
+
* `cancelled` is IGNORED because a second push to main cancels the first
|
|
67
|
+
* (the `concurrency` block in .github/workflows/tests.yml), so a cancelled run
|
|
68
|
+
* is not a verdict on anything; the newer push's own watch carries it.
|
|
69
|
+
* `startup_failure` is ignored because it is a GitHub ACCOUNT problem rather
|
|
70
|
+
* than a test: for three weeks every run here ended that way, and the answer
|
|
71
|
+
* was a payment method, not a commit (the head of that workflow file says so).
|
|
72
|
+
* A row per push during a spell like that is noise with no fix in it.
|
|
73
|
+
*/
|
|
74
|
+
export function verdict(run) {
|
|
75
|
+
if (!run || run.status !== 'completed') return 'waiting';
|
|
76
|
+
const how = String(run.conclusion ?? '');
|
|
77
|
+
if (how === 'success') return 'passing';
|
|
78
|
+
if (how === 'failure' || how === 'timed_out') return 'failing';
|
|
79
|
+
return 'ignore';
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Which machine a job ran on, by the label the workflow asked for. */
|
|
83
|
+
export function machineOf(job) {
|
|
84
|
+
const labels = (job?.labels ?? []).join(' ').toLowerCase();
|
|
85
|
+
const name = String(job?.name ?? '');
|
|
86
|
+
if (/ubuntu|linux/.test(`${labels} ${name}`)) return 'Linux';
|
|
87
|
+
if (/macos/.test(`${labels} ${name}`)) return 'macOS';
|
|
88
|
+
if (/windows/.test(`${labels} ${name}`)) return 'Windows';
|
|
89
|
+
return name || 'that machine';
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const ANSI = /\u001b\[[0-9;]*m/g;
|
|
93
|
+
// A job log is one timestamped line per line of output, and GitHub's own
|
|
94
|
+
// lines carry a kind in front of them.
|
|
95
|
+
const STAMP = /^\S+Z\s/;
|
|
96
|
+
const GITHUB_SAYS = /^##\[[a-z]+\]/;
|
|
97
|
+
const clean = (raw) => String(raw).replace(ANSI, '').replace(STAMP, '').replace(GITHUB_SAYS, '').trim();
|
|
98
|
+
// Vitest's own `FAIL tests/x.test.mjs > suite > case`, and ONLY at the start
|
|
99
|
+
// of the line. A LINE VITEST QUOTED IS NOT THE RUNNER SPEAKING: the ship queue
|
|
100
|
+
// learned that three times over (w-560647d4db). A failing text assertion
|
|
101
|
+
// prints the file's own source as a diff (`+`/`-`) and as a code frame
|
|
102
|
+
// (`34| ...`), and this repo's test files are sentences, so plenty of them
|
|
103
|
+
// carry "failed" or "FAIL" inside a name. Anchoring at FAIL rules all of that
|
|
104
|
+
// out, and the tick vitest puts on a file it ran green rules out the rest.
|
|
105
|
+
const NAMES_A_RED_TEST = /^FAIL\s+(\S+?\.(?:test|spec)\.[a-z]+)(?:\s|$)/;
|
|
106
|
+
|
|
107
|
+
/** Every test file the runner printed a FAIL for, once each, in order. */
|
|
108
|
+
export function failedTestsIn(log) {
|
|
109
|
+
const found = [];
|
|
110
|
+
for (const raw of String(log ?? '').split('\n')) {
|
|
111
|
+
const name = NAMES_A_RED_TEST.exec(clean(raw))?.[1];
|
|
112
|
+
if (name && !found.includes(name)) found.push(name);
|
|
113
|
+
}
|
|
114
|
+
return found;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// VITEST EXITS 1 FOR THINGS THAT ARE NOT A FAILING TEST, and none of them
|
|
118
|
+
// print FAIL: an error thrown after a file's tests have passed, a collection
|
|
119
|
+
// that never started, a worker that died. The row used to stop at "names no
|
|
120
|
+
// failing test" with the log it had already fetched left unread.
|
|
121
|
+
//
|
|
122
|
+
// A banner is matched along with the rule vitest draws it in, because a line
|
|
123
|
+
// vitest QUOTED is not the runner speaking. The plural heading over the whole
|
|
124
|
+
// section is not one of these, and the three lines of advice are about
|
|
125
|
+
// unhandled errors in general rather than about this run.
|
|
126
|
+
const UNHANDLED = /^⎯+\s*Unhandled (?:Error|Rejection)\s*⎯*$/;
|
|
127
|
+
const NAMES_A_BREAKAGE = /^(?:No test files found|FATAL ERROR:)/;
|
|
128
|
+
const ADVICE = /^(?:Vitest caught|This might cause|Serialized Error)/;
|
|
129
|
+
const SAID_AT_MOST = 5;
|
|
130
|
+
|
|
131
|
+
/** What the runner itself said, for a row that can name no failing test. */
|
|
132
|
+
export function whatTheRunnerSaid(log) {
|
|
133
|
+
const lines = String(log ?? '').split('\n').map(clean);
|
|
134
|
+
const said = [];
|
|
135
|
+
const keep = (line) => {
|
|
136
|
+
if (line && !ADVICE.test(line) && !said.includes(line)) said.push(line);
|
|
137
|
+
};
|
|
138
|
+
lines.forEach((line, i) => {
|
|
139
|
+
if (UNHANDLED.test(line)) keep(lines.slice(i + 1, i + 8).find((l) => l && !ADVICE.test(l)));
|
|
140
|
+
else if (NAMES_A_BREAKAGE.test(line)) keep(line);
|
|
141
|
+
});
|
|
142
|
+
return said.slice(0, SAID_AT_MOST);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const shortSha = (sha) => String(sha ?? '').slice(0, 7);
|
|
146
|
+
const NOTHING_NAMED = 'nothing in its log names a failing test';
|
|
147
|
+
// A log `gh` could not hand over says nothing about the run, and a row that
|
|
148
|
+
// reported it as "names no failing test" would be making that up.
|
|
149
|
+
const NOT_READ = 'its log could not be read from here';
|
|
150
|
+
const unread = (machines) => machines.length > 0 && machines.every((m) => m.read === false);
|
|
151
|
+
/** The first thing any machine said, cut to something a title can hold. */
|
|
152
|
+
const firstWords = (machines, max) => {
|
|
153
|
+
const said = machines.flatMap((m) => m.said ?? []).find(Boolean);
|
|
154
|
+
if (!said) return '';
|
|
155
|
+
return said.length > max ? `${said.slice(0, max - 1).trimEnd()}…` : said;
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
/** The row's title: the one test, or the count and the machines. */
|
|
159
|
+
export function redTitle(machines) {
|
|
160
|
+
const names = [...new Set(machines.flatMap((m) => m.tests))];
|
|
161
|
+
if (names.length === 1) return `main went red on GitHub: ${names[0]}`;
|
|
162
|
+
if (!names.length) {
|
|
163
|
+
const said = firstWords(machines, 58);
|
|
164
|
+
if (said) return `main went red on GitHub: ${said}`;
|
|
165
|
+
if (unread(machines)) return `main went red on GitHub, and ${NOT_READ}`;
|
|
166
|
+
return 'main went red on GitHub, and its log names no failing test';
|
|
167
|
+
}
|
|
168
|
+
const where = machines.filter((m) => m.tests.length).map((m) => m.machine);
|
|
169
|
+
return `main went red on GitHub: ${names.length} tests, on ${where.join(' and ')}`;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** The row's Context: what a reader gets before they open anything. */
|
|
173
|
+
export function redContext({ sha, machines }) {
|
|
174
|
+
const names = [...new Set(machines.flatMap((m) => m.tests))];
|
|
175
|
+
const where = machines.filter((m) => m.tests.length).map((m) => m.machine);
|
|
176
|
+
if (!names.length) {
|
|
177
|
+
const said = firstWords(machines, 110);
|
|
178
|
+
let how = NOTHING_NAMED;
|
|
179
|
+
if (said) how = `no test failed and the runner said "${said}"`;
|
|
180
|
+
else if (unread(machines)) how = NOT_READ;
|
|
181
|
+
return `main's tests run went red on GitHub at ${shortSha(sha)}, and ${how}. The run itself is in the task.`;
|
|
182
|
+
}
|
|
183
|
+
return [
|
|
184
|
+
`main's tests run went red on GitHub at ${shortSha(sha)}, on ${where.join(' and ')}.`,
|
|
185
|
+
`${names.length === 1 ? 'The failing test' : `The ${names.length} failing tests`}: ${names.join(', ')}.`,
|
|
186
|
+
'Which machine each failed on, and the run, are in the task.',
|
|
187
|
+
].join(' ');
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** The whole row: what failed, where, and the commit that did it. */
|
|
191
|
+
export function redRow({ sha, subject, url, machines }) {
|
|
192
|
+
return {
|
|
193
|
+
title: redTitle(machines),
|
|
194
|
+
problem: redContext({ sha, machines }),
|
|
195
|
+
body: [
|
|
196
|
+
"main's `tests` run went red on GitHub, at this commit:",
|
|
197
|
+
'',
|
|
198
|
+
` ${shortSha(sha)} ${subject || 'no title'}`,
|
|
199
|
+
'',
|
|
200
|
+
...machines.flatMap(({ machine, tests, said, read }) => [
|
|
201
|
+
`${machine}:`,
|
|
202
|
+
...(tests.length ? tests.map((t) => `- ${t}`) : [
|
|
203
|
+
`- ${read === false ? NOT_READ : NOTHING_NAMED}${said?.length ? ', and the runner said:' : ''}`,
|
|
204
|
+
...(said ?? []).map((line) => ` ${line}`),
|
|
205
|
+
]),
|
|
206
|
+
'',
|
|
207
|
+
]),
|
|
208
|
+
...(url ? [`The whole run: ${url}`, ''] : []),
|
|
209
|
+
'That run finishes about five minutes after the agent that shipped has',
|
|
210
|
+
'already ended, so nobody is watching it: main was red here from',
|
|
211
|
+
'2026-10-09 to 2026-10-13 and nothing said so. This is the only row filed',
|
|
212
|
+
'while main stays red, and a note lands here when it goes green again.',
|
|
213
|
+
].join('\n'),
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** What that row hears when main comes back. */
|
|
218
|
+
export function greenAgain({ sha, subject }) {
|
|
219
|
+
return `main's tests are green again on GitHub, as of ${shortSha(sha)}${subject ? ` (${subject})` : ''}.`;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const sleep = (ms) => new Promise((resolve) => { setTimeout(resolve, ms).unref?.(); });
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* The watch itself: one per successful ship, and never anything a person waits
|
|
226
|
+
* on.
|
|
227
|
+
*
|
|
228
|
+
* WHETHER MAIN IS KNOWN RED LIVES IN THE APP'S OWN DATA FOLDER, beside the
|
|
229
|
+
* ship settings, because it is a fact about this Mac's watching rather than
|
|
230
|
+
* about anyone's work. With no file, main counts as fine, so the FIRST red run
|
|
231
|
+
* this ever sees files a row: requiring a green first would have kept it silent
|
|
232
|
+
* through exactly the four days that put this here.
|
|
233
|
+
*/
|
|
234
|
+
export class MainTests {
|
|
235
|
+
constructor({ store, userDir, gh = runGh, wait = sleep, now = Date.now, log = console } = {}) {
|
|
236
|
+
Object.assign(this, { store, userDir, gh, wait, now, log });
|
|
237
|
+
this.stopped = false;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
stop() { this.stopped = true; }
|
|
241
|
+
|
|
242
|
+
stateFile() { return path.join(this.userDir, STATE); }
|
|
243
|
+
|
|
244
|
+
read() {
|
|
245
|
+
try {
|
|
246
|
+
const raw = JSON.parse(fs.readFileSync(this.stateFile(), 'utf8'));
|
|
247
|
+
return raw && typeof raw === 'object' ? raw : {};
|
|
248
|
+
} catch { return {}; }
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
write(state) {
|
|
252
|
+
try {
|
|
253
|
+
const tmp = `${this.stateFile()}.${process.pid}.tmp`;
|
|
254
|
+
fs.writeFileSync(tmp, `${JSON.stringify(state)}\n`);
|
|
255
|
+
fs.renameSync(tmp, this.stateFile());
|
|
256
|
+
} catch (e) { this.log.warn?.(`main-tests: could not record where main stands: ${e.message}`); }
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** gh's JSON, or null if this machine cannot ask. */
|
|
260
|
+
async ask(args, cwd) {
|
|
261
|
+
const { code, out } = await this.gh(args, { cwd });
|
|
262
|
+
if (code !== 0) return null;
|
|
263
|
+
try { return JSON.parse(out); } catch { return null; }
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
async watch({ product, sha } = {}) {
|
|
267
|
+
const cwd = product?.repoPath;
|
|
268
|
+
if (!cwd || !sha || !fs.existsSync(cwd)) return null;
|
|
269
|
+
const until = this.now() + GIVE_UP_MS;
|
|
270
|
+
while (!this.stopped) {
|
|
271
|
+
const runs = await this.ask(
|
|
272
|
+
['run', 'list', '--branch', 'main', '--workflow', WORKFLOW, '-L', '20',
|
|
273
|
+
'--json', 'databaseId,headSha,status,conclusion,displayTitle,url'],
|
|
274
|
+
cwd,
|
|
275
|
+
);
|
|
276
|
+
// No gh, signed out, or no access to this repository: nothing, quietly.
|
|
277
|
+
if (!runs) return null;
|
|
278
|
+
const run = runFor(runs, sha);
|
|
279
|
+
const says = verdict(run);
|
|
280
|
+
if (says === 'ignore') return null;
|
|
281
|
+
if (says === 'passing') return this.wentGreen(product, run);
|
|
282
|
+
if (says === 'failing') return this.wentRed(product, run, cwd);
|
|
283
|
+
if (this.now() >= until) {
|
|
284
|
+
this.log.info?.(`main-tests: ${shortSha(sha)}'s run has not finished in half an hour; leaving it.`);
|
|
285
|
+
return null;
|
|
286
|
+
}
|
|
287
|
+
await this.wait(POLL_MS);
|
|
288
|
+
}
|
|
289
|
+
return null;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** One row, and only while main was not already known red. */
|
|
293
|
+
async wentRed(product, run, cwd) {
|
|
294
|
+
const state = this.read();
|
|
295
|
+
if (state.red) return null;
|
|
296
|
+
const jobs = (await this.ask(['api', `repos/{owner}/{repo}/actions/runs/${run.databaseId}/jobs`, '--paginate'], cwd))?.jobs ?? [];
|
|
297
|
+
const machines = [];
|
|
298
|
+
for (const job of jobs) {
|
|
299
|
+
if (String(job?.conclusion ?? '') !== 'failure') continue;
|
|
300
|
+
const { code, out } = await this.gh(['api', `repos/{owner}/{repo}/actions/jobs/${job.id}/logs`], { cwd });
|
|
301
|
+
const read = code === 0;
|
|
302
|
+
const tests = read ? failedTestsIn(out) : [];
|
|
303
|
+
machines.push({ machine: machineOf(job), tests, read, said: read && !tests.length ? whatTheRunnerSaid(out) : [] });
|
|
304
|
+
}
|
|
305
|
+
const row = redRow({ sha: run.headSha, subject: run.displayTitle, url: run.url, machines });
|
|
306
|
+
let filed = null;
|
|
307
|
+
try {
|
|
308
|
+
filed = this.store.fileItem(product.slug, { ...row, kind: 'task' });
|
|
309
|
+
} catch (e) {
|
|
310
|
+
this.log.warn?.(`main-tests: could not file the red main: ${e.message}`);
|
|
311
|
+
return null;
|
|
312
|
+
}
|
|
313
|
+
this.write({ ...state, red: { product: product.slug, id: filed.id, sha: run.headSha } });
|
|
314
|
+
this.log.info?.(`main-tests: main went red at ${shortSha(run.headSha)}; filed ${filed.id}.`);
|
|
315
|
+
return filed;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** The green again, on the row that reported the red. */
|
|
319
|
+
wentGreen(product, run) {
|
|
320
|
+
const state = this.read();
|
|
321
|
+
if (!state.red) return null;
|
|
322
|
+
try {
|
|
323
|
+
this.store.noteItem(state.red.product, state.red.id, greenAgain({ sha: run.headSha, subject: run.displayTitle }));
|
|
324
|
+
} catch (e) {
|
|
325
|
+
this.log.warn?.(`main-tests: could not say main is green again: ${e.message}`);
|
|
326
|
+
}
|
|
327
|
+
this.write({ ...state, red: null });
|
|
328
|
+
return null;
|
|
329
|
+
}
|
|
330
|
+
}
|