agentbox-app 0.1.7 → 0.1.12
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 +220 -58
- package/bin/agentbox.mjs +18 -3
- package/briefs/message-rules.md +24 -3
- package/briefs/worker.md +63 -49
- package/briefs/writing-rules.md +90 -131
- package/main/account-discovery.mjs +21 -20
- package/main/account-tooling.mjs +11 -11
- package/main/agent-activity.mjs +45 -5
- package/main/agent-schedule.mjs +14 -14
- package/main/agent-sessions.mjs +94 -10
- package/main/agent-updates.mjs +116 -0
- package/main/agents.mjs +52 -46
- package/main/analytics.mjs +27 -0
- package/main/approval-prompt-server.mjs +2 -2
- package/main/approvals.mjs +14 -5
- package/main/artifact-path.mjs +11 -0
- package/main/claude-bin.mjs +32 -5
- package/main/claude-input.mjs +45 -3
- package/main/claude-plan.mjs +25 -13
- package/main/code-change.mjs +3 -3
- package/main/codex-account.mjs +1 -1
- package/main/codex-bin.mjs +18 -0
- package/main/codex-launch-env.mjs +31 -0
- package/main/codex-memory-gate.mjs +260 -0
- package/main/codex-models.mjs +12 -7
- package/main/codex-threads.mjs +46 -23
- package/main/codex-usage-file.mjs +7 -7
- package/main/codex-usage.mjs +4 -4
- package/main/codex-watch.mjs +12 -12
- package/main/config.mjs +82 -17
- package/main/demo.mjs +17 -14
- package/main/dev-window.mjs +9 -9
- package/main/doc-scheme.mjs +2 -0
- package/main/engine-setup.mjs +204 -0
- package/main/feedback.mjs +55 -0
- package/main/folder-pictures.mjs +48 -0
- package/main/frame-scrollbars.mjs +90 -0
- package/main/fresh-user.mjs +7 -4
- package/main/git-change-offthread.mjs +70 -0
- package/main/git-change-thread.mjs +11 -0
- package/main/git-change.mjs +9 -3
- package/main/img-scheme.mjs +70 -1
- package/main/instruction-settings.mjs +134 -60
- package/main/ipc.mjs +335 -42
- package/main/leftovers.mjs +518 -0
- package/main/live-replies.mjs +43 -5
- package/main/machine.mjs +48 -1
- package/main/main.mjs +138 -15
- package/main/memory-gate-history.mjs +254 -0
- package/main/memory-gate-server.mjs +461 -0
- package/main/memory-gate.mjs +206 -0
- package/main/message-priority.mjs +87 -0
- package/main/notify.mjs +1 -1
- package/main/rail-note.mjs +2 -2
- package/main/refresh-agent-models.mjs +21 -0
- package/main/repeats.mjs +4 -4
- package/main/row-label.mjs +115 -51
- package/main/runs-on.mjs +93 -0
- package/main/serve.mjs +77 -5
- package/main/settings.mjs +245 -26
- package/main/ship-queue.mjs +279 -0
- package/main/sign-in-files.mjs +39 -0
- package/main/source-updater.mjs +298 -0
- package/main/store/home.mjs +84 -7
- package/main/store/project.mjs +15 -19
- package/main/store/work-items.mjs +150 -6
- package/main/store.mjs +245 -13
- package/main/supervisor.mjs +1634 -417
- package/main/task-commands.mjs +8 -10
- package/main/task-folders.mjs +94 -16
- package/main/task-remote-control.mjs +3 -3
- package/main/task-terminals.mjs +14 -2
- package/main/team/index.mjs +440 -0
- package/main/team/memory-cloud.mjs +247 -0
- package/main/team/projects.mjs +162 -0
- package/main/team/session.mjs +225 -0
- package/main/team/sign-in.mjs +106 -0
- package/main/team/supabase-backend.mjs +231 -0
- package/main/team/sync.mjs +163 -0
- package/main/updater.mjs +15 -5
- package/main/write-before-reload.mjs +50 -0
- package/mcp/core/page.mjs +10 -12
- package/mcp/core/work.mjs +123 -26
- package/mcp/tools.mjs +13 -6
- package/package.json +21 -9
- package/renderer/dist/assets/agentbox-icon-F1oN9nj_.svg +15 -0
- package/renderer/dist/assets/index-ChKsQkRw.js +1169 -0
- package/renderer/dist/assets/index-ClBXnq3B.css +32 -0
- package/renderer/dist/index.html +2 -2
- package/scripts/after-install.mjs +14 -0
- package/scripts/approval-server.sh +7 -2
- package/scripts/memory-gate-hook.sh +59 -0
- package/shared/agent-import.mjs +39 -16
- package/shared/agents.d.mts +26 -8
- package/shared/agents.mjs +100 -108
- package/shared/analytics-events.mjs +19 -2
- package/shared/answers.mjs +12 -0
- package/shared/bridge-map.d.mts +12 -0
- package/shared/bridge-map.mjs +38 -1
- package/shared/claude-commands.generated.mjs +2 -2
- package/shared/claude-models.generated.mjs +6 -6
- package/shared/claude-usage.mjs +6 -6
- package/shared/codex-import.mjs +7 -7
- package/shared/codex-modes.d.mts +50 -0
- package/shared/codex-modes.mjs +1 -1
- package/shared/codex-usage.mjs +7 -8
- package/shared/demo-world.mjs +6 -6
- package/shared/engines.mjs +27 -10
- package/shared/feedback.d.mts +14 -0
- package/shared/feedback.mjs +44 -0
- package/shared/first-run-examples.mjs +4 -4
- package/shared/first-run-practice.d.mts +16 -0
- package/shared/first-run-practice.mjs +176 -70
- package/shared/fresh-user-home.mjs +34 -4
- package/shared/instruction-defaults.json +2 -2
- package/shared/product-name.mjs +4 -2
- package/shared/project-folder-check.mjs +9 -3
- package/shared/rank.d.mts +1 -0
- package/shared/rank.mjs +22 -0
- package/shared/referenced-files.mjs +1 -1
- package/shared/runs-on.d.mts +20 -0
- package/shared/runs-on.mjs +79 -0
- package/shared/schemes.d.mts +11 -0
- package/shared/schemes.mjs +1 -1
- package/shared/shortcuts.d.mts +41 -0
- package/shared/shortcuts.mjs +236 -0
- package/shared/spawn-trouble.d.mts +2 -0
- package/shared/spawn-trouble.mjs +40 -19
- package/shared/team-rules.d.mts +17 -0
- package/shared/team-rules.mjs +230 -0
- package/shared/team-status.d.mts +11 -0
- package/shared/team-status.mjs +90 -0
- package/shared/terminal-links.d.mts +2 -0
- package/shared/terminal-links.mjs +55 -0
- package/shared/terminal-state.d.mts +1 -0
- package/shared/terminal-state.mjs +16 -0
- package/shared/thread-cards.d.mts +19 -0
- package/shared/thread-cards.mjs +254 -0
- package/shared/usage.d.mts +1 -1
- package/shared/usage.mjs +6 -7
- package/shared/work-items.mjs +258 -28
- package/shared/work-lines.mjs +393 -16
- package/worker-permissions.json +1 -1
- package/renderer/dist/assets/cel-dusk-CS5I7kJS.webp +0 -0
- package/renderer/dist/assets/cel-dusk-DUslqS6B.webp +0 -0
- package/renderer/dist/assets/cel-dusk-Jt_xVGxp.webp +0 -0
- package/renderer/dist/assets/cel-dusk-sm-BBkV-tWT.webp +0 -0
- package/renderer/dist/assets/frost-haze-CxPSOBEg.webp +0 -0
- package/renderer/dist/assets/frost-haze-sm-C_w21qgd.webp +0 -0
- package/renderer/dist/assets/ice-BC8luINc.webp +0 -0
- package/renderer/dist/assets/ice-CP9PrKHo.webp +0 -0
- package/renderer/dist/assets/ice-river-D6ziOhLY.webp +0 -0
- package/renderer/dist/assets/ice-river-UVM2-JY_.webp +0 -0
- package/renderer/dist/assets/ice-river-bErJwYWW.webp +0 -0
- package/renderer/dist/assets/ice-river-sm-Bg1U-SIj.webp +0 -0
- package/renderer/dist/assets/ice-sm-CbVcg3_D.webp +0 -0
- package/renderer/dist/assets/index-B7hhfHrT.js +0 -684
- package/renderer/dist/assets/index-CwiDv5bL.css +0 -32
- package/renderer/dist/assets/lake-C0WaW-6E.webp +0 -0
- package/renderer/dist/assets/lake-CSKjLC5K.webp +0 -0
- package/renderer/dist/assets/lake-ETGcOb25.webp +0 -0
- package/renderer/dist/assets/lake-harbour-BCQRbrHb.webp +0 -0
- package/renderer/dist/assets/lake-harbour-B_wfXTmE.webp +0 -0
- package/renderer/dist/assets/lake-harbour-Te1pEvBh.webp +0 -0
- package/renderer/dist/assets/lake-harbour-sm-Cme_ShEg.webp +0 -0
- package/renderer/dist/assets/lake-sm-CbG72Hbv.webp +0 -0
- package/renderer/dist/assets/lino-coast-CUZHcucI.webp +0 -0
- package/renderer/dist/assets/lino-coast-DP4J7cgd.webp +0 -0
- package/renderer/dist/assets/lino-coast-kOIAffr_.webp +0 -0
- package/renderer/dist/assets/lino-coast-sm-C1Mdbgit.webp +0 -0
- package/renderer/dist/assets/moon-pines-BskgnnXR.webp +0 -0
- package/renderer/dist/assets/moon-pines-CFSIIwt0.webp +0 -0
- package/renderer/dist/assets/moon-pines-PNhdOYww.webp +0 -0
- package/renderer/dist/assets/moon-pines-sm-C6K1NomK.webp +0 -0
- package/renderer/dist/assets/orbit-rings-2XV36Ch3.webp +0 -0
- package/renderer/dist/assets/orbit-rings-C4NrcYGQ.webp +0 -0
- package/renderer/dist/assets/orbit-rings-DGLk07dT.webp +0 -0
- package/renderer/dist/assets/orbit-rings-sm-Dky3YdvL.webp +0 -0
- package/renderer/dist/assets/peach-haze-2-C6f7xnWw.webp +0 -0
- package/renderer/dist/assets/peach-haze-2-sm-Dj_Ez_Sb.webp +0 -0
- package/renderer/dist/assets/pixel-harbour-DQjqMROg.webp +0 -0
- package/renderer/dist/assets/pixel-harbour-Dw71H_bc.webp +0 -0
- package/renderer/dist/assets/pixel-harbour-kOZfm-GM.webp +0 -0
- package/renderer/dist/assets/pixel-harbour-sm-Dturjuky.webp +0 -0
- package/renderer/dist/assets/pixel-orbit-C296JVMO.webp +0 -0
- package/renderer/dist/assets/pixel-orbit-C4oMRDSx.webp +0 -0
- package/renderer/dist/assets/pixel-orbit-CqVmQ4eh.webp +0 -0
- package/renderer/dist/assets/pixel-orbit-sm-CHagC_nO.webp +0 -0
- package/renderer/dist/assets/pixel-ridge-C6LYu6uE.webp +0 -0
- package/renderer/dist/assets/pixel-ridge-CIUCEESs.webp +0 -0
- package/renderer/dist/assets/pixel-ridge-sm-BnE0F_w-.webp +0 -0
- package/renderer/dist/assets/riso-hills-7heKVtry.webp +0 -0
- package/renderer/dist/assets/riso-hills-CIjAL3J6.webp +0 -0
- package/renderer/dist/assets/riso-hills-sm-DHsnbCfP.webp +0 -0
- package/renderer/dist/assets/slate-haze-BPWdkNsw.webp +0 -0
- package/renderer/dist/assets/slate-haze-sm-B0Cb5a2W.webp +0 -0
- package/renderer/dist/assets/valley-haze-BDHPqoJy.webp +0 -0
- package/renderer/dist/assets/valley-haze-sm-CTyjmPAG.webp +0 -0
- package/renderer/dist/assets/vector-dunes-DzzWLdKo.webp +0 -0
- package/renderer/dist/assets/vector-dunes-Yr9NBQUy.webp +0 -0
- package/renderer/dist/assets/vector-dunes-sm-D8MYY6ms.webp +0 -0
- package/renderer/dist/assets/watercolour-mist-BCGKN4u4.webp +0 -0
- package/renderer/dist/assets/watercolour-mist-BF-jk5MC.webp +0 -0
- package/renderer/dist/assets/watercolour-mist-sm-Ds1xzTw9.webp +0 -0
- package/renderer/dist/assets/woodblock-sea-B1um67Z9.webp +0 -0
- package/renderer/dist/assets/woodblock-sea-DKQdYs32.webp +0 -0
- package/renderer/dist/assets/woodblock-sea-sm-BJzNq8xh.webp +0 -0
- package/renderer/dist/emulator/audio-rules.mjs +0 -74
- package/renderer/dist/emulator/data/compression/README.md +0 -11
- package/renderer/dist/emulator/data/compression/extract7z.js +0 -1
- package/renderer/dist/emulator/data/compression/extractzip.js +0 -1
- package/renderer/dist/emulator/data/compression/libunrar.js +0 -519
- package/renderer/dist/emulator/data/compression/libunrar.wasm +0 -0
- package/renderer/dist/emulator/data/cores/README.md +0 -22
- package/renderer/dist/emulator/data/cores/mgba-legacy-wasm.data +0 -0
- package/renderer/dist/emulator/data/cores/mgba-thread-legacy-wasm.data +0 -0
- package/renderer/dist/emulator/data/cores/mgba-thread-wasm.data +0 -0
- package/renderer/dist/emulator/data/cores/mgba-wasm.data +0 -0
- package/renderer/dist/emulator/data/cores/package.json +0 -65
- package/renderer/dist/emulator/data/cores/reports/mgba.json +0 -1
- package/renderer/dist/emulator/data/emulator.css +0 -1614
- package/renderer/dist/emulator/data/loader.js +0 -170
- package/renderer/dist/emulator/data/localization/README.md +0 -63
- package/renderer/dist/emulator/data/localization/af-FR.json +0 -339
- package/renderer/dist/emulator/data/localization/ar-AR.json +0 -301
- package/renderer/dist/emulator/data/localization/ben-BEN.json +0 -301
- package/renderer/dist/emulator/data/localization/de-GER.json +0 -301
- package/renderer/dist/emulator/data/localization/el-GR.json +0 -301
- package/renderer/dist/emulator/data/localization/en-US.json +0 -339
- package/renderer/dist/emulator/data/localization/es-ES.json +0 -310
- package/renderer/dist/emulator/data/localization/fa-AF.json +0 -310
- package/renderer/dist/emulator/data/localization/hi-HI.json +0 -301
- package/renderer/dist/emulator/data/localization/it-IT.json +0 -302
- package/renderer/dist/emulator/data/localization/ja-JA.json +0 -301
- package/renderer/dist/emulator/data/localization/jv-JV.json +0 -301
- package/renderer/dist/emulator/data/localization/ko-KO.json +0 -336
- package/renderer/dist/emulator/data/localization/pt-BR.json +0 -361
- package/renderer/dist/emulator/data/localization/retroarch.json +0 -617
- package/renderer/dist/emulator/data/localization/ro-RO.json +0 -310
- package/renderer/dist/emulator/data/localization/ru-RU.json +0 -301
- package/renderer/dist/emulator/data/localization/tr-TR.json +0 -310
- package/renderer/dist/emulator/data/localization/vi-VN.json +0 -302
- package/renderer/dist/emulator/data/localization/zh-CN.json +0 -351
- package/renderer/dist/emulator/data/src/GameManager.js +0 -464
- package/renderer/dist/emulator/data/src/compression.js +0 -142
- package/renderer/dist/emulator/data/src/emulator.js +0 -6158
- package/renderer/dist/emulator/data/src/gamepad.js +0 -167
- package/renderer/dist/emulator/data/src/nipplejs.js +0 -1
- package/renderer/dist/emulator/data/src/shaders.js +0 -236
- package/renderer/dist/emulator/data/src/socket.io.min.js +0 -7
- package/renderer/dist/emulator/data/src/storage.js +0 -125
- package/renderer/dist/emulator/data/version.json +0 -5
- package/renderer/dist/emulator/host.js +0 -299
- package/renderer/dist/emulator/index.html +0 -28
- package/renderer/dist/emulator/provenance.json +0 -6
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
// THE APP SHIPS A TASK; THE AGENT ONLY SAYS IT IS READY (2026-10-02).
|
|
2
|
+
//
|
|
3
|
+
// Agents used to finish by pushing to main and updating the folder the app
|
|
4
|
+
// runs from. Over thirty hours on one Mac, 17 tasks stalled because Claude
|
|
5
|
+
// Code's auto-mode safety check refused one of those steps (a push to a public
|
|
6
|
+
// repo, a fast-forward of another checkout, a rebase), 16 because a folder was
|
|
7
|
+
// left mid-merge or committed into, 6 racing each other's pushes. A refusal is
|
|
8
|
+
// final and asks nobody, so nothing reached the approvals corner; the task
|
|
9
|
+
// just said "the permission check refused it" and waited.
|
|
10
|
+
//
|
|
11
|
+
// So for a project that turns this on, the agent stops at a branch: it commits
|
|
12
|
+
// in its task folder, adds the label `ship` to its row, and ends its turn.
|
|
13
|
+
// This queue then runs the project's own ship script, one task at a time
|
|
14
|
+
// across the whole Mac, so two tasks never race. A failure goes back to the
|
|
15
|
+
// agent that wrote the change, as a reply, with the script's own words.
|
|
16
|
+
//
|
|
17
|
+
// OFF UNLESS A PERSON TURNS IT ON. The setting lives in the app's own data
|
|
18
|
+
// folder (`ship.json`), never in the store or the repo: agents can write
|
|
19
|
+
// project files, and a ship command they could rewrite would be a command the
|
|
20
|
+
// app runs for them outside every check. The script it names is run FROM THE
|
|
21
|
+
// BASE CHECKOUT, which only ever fast-forwards to the remote, so a branch
|
|
22
|
+
// cannot change the code that ships it. With no `ship.json`, nothing here does
|
|
23
|
+
// anything at all.
|
|
24
|
+
|
|
25
|
+
import { spawn } from 'node:child_process';
|
|
26
|
+
import fs from 'node:fs';
|
|
27
|
+
import path from 'node:path';
|
|
28
|
+
import { Name } from '../shared/product-name.mjs';
|
|
29
|
+
|
|
30
|
+
export const SHIP_LABEL = 'ship';
|
|
31
|
+
const SHIP_MS = 20 * 60 * 1000;
|
|
32
|
+
const KEEP = 4000;
|
|
33
|
+
|
|
34
|
+
export const shipSettingsFile = (userDir) => path.join(userDir, 'ship.json');
|
|
35
|
+
|
|
36
|
+
// WHICH "READY" EACH TASK HAS ALREADY BEEN SHIPPED FOR, by the moment its
|
|
37
|
+
// label was written. Taking the label off is not enough: a label the person
|
|
38
|
+
// put on outranks the app on the ledger, so the app's removal is ignored, and
|
|
39
|
+
// on the first real run that shipped a task and then ran it twice more
|
|
40
|
+
// (2026-10-02). A new label carries a new moment and ships again.
|
|
41
|
+
const handledFile = (userDir) => path.join(userDir, 'ship-handled.json');
|
|
42
|
+
const keyOf = (item) => `${item.product}/${item.id}`;
|
|
43
|
+
const markedAt = (item) => item.wrote?.labels?.ts ?? item.updatedAt ?? 0;
|
|
44
|
+
function readHandled(userDir) {
|
|
45
|
+
try { const raw = JSON.parse(fs.readFileSync(handledFile(userDir), 'utf8')); return raw && typeof raw === 'object' ? raw : {}; } catch { return {}; }
|
|
46
|
+
}
|
|
47
|
+
function writeHandled(userDir, all) {
|
|
48
|
+
try {
|
|
49
|
+
const tmp = `${handledFile(userDir)}.${process.pid}.tmp`;
|
|
50
|
+
fs.writeFileSync(tmp, `${JSON.stringify(all)}\n`);
|
|
51
|
+
fs.renameSync(tmp, handledFile(userDir));
|
|
52
|
+
} catch { /* the label removal still covers the ordinary case */ }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** `{ [projectSlug]: { script: 'scripts/ship.mjs' } }`, or `{}`. */
|
|
56
|
+
export function readShipSettings(userDir) {
|
|
57
|
+
try {
|
|
58
|
+
const raw = JSON.parse(fs.readFileSync(shipSettingsFile(userDir), 'utf8'));
|
|
59
|
+
return raw && typeof raw === 'object' && !Array.isArray(raw) ? raw : {};
|
|
60
|
+
} catch {
|
|
61
|
+
return {};
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The script that ships this project, inside its base checkout, or null. */
|
|
66
|
+
export function shipScriptFor(product, settings) {
|
|
67
|
+
const rel = settings?.[product?.slug]?.script;
|
|
68
|
+
if (!product?.repoPath || typeof rel !== 'string' || !rel || path.isAbsolute(rel)) return null;
|
|
69
|
+
const base = path.resolve(product.repoPath);
|
|
70
|
+
const script = path.resolve(base, rel);
|
|
71
|
+
if (!script.startsWith(base + path.sep)) return null;
|
|
72
|
+
return fs.existsSync(script) ? script : null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const labelsOf = (item) => (Array.isArray(item?.labels) ? item.labels : []);
|
|
76
|
+
export const wantsShipping = (item) => labelsOf(item).includes(SHIP_LABEL);
|
|
77
|
+
export const withoutShipLabel = (item) => labelsOf(item).filter((l) => l !== SHIP_LABEL);
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The next task to ship, oldest ask first, or null.
|
|
81
|
+
*
|
|
82
|
+
* Only a row of a project that ships, marked `ship`, with no session on it
|
|
83
|
+
* (an agent still working may yet commit), and with a task folder to ship from.
|
|
84
|
+
*/
|
|
85
|
+
export function nextToShip(items, products, settings, { isLive = () => false, folderFor, handled = {} }) {
|
|
86
|
+
const bySlug = new Map(products.map((p) => [p.slug, p]));
|
|
87
|
+
const ready = items
|
|
88
|
+
.filter((i) => wantsShipping(i) && i.status !== 'done' && !isLive(i) && !(markedAt(i) <= (handled[keyOf(i)] ?? -1)))
|
|
89
|
+
.map((item) => {
|
|
90
|
+
const product = bySlug.get(item.product);
|
|
91
|
+
const script = product ? shipScriptFor(product, settings) : null;
|
|
92
|
+
const cwd = script ? folderFor(item, product) : null;
|
|
93
|
+
return script && cwd && fs.existsSync(cwd) ? { item, product, script, cwd } : null;
|
|
94
|
+
})
|
|
95
|
+
.filter(Boolean);
|
|
96
|
+
ready.sort((a, b) => markedAt(a.item) - markedAt(b.item));
|
|
97
|
+
return ready[0] ?? null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** The commit the script reports, from its own `ship: shipped <sha>.` line. */
|
|
101
|
+
export function shippedSha(output) {
|
|
102
|
+
return String(output ?? '').match(/ship: shipped ([0-9a-f]{7,40})\./)?.[1] ?? null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// A LINE VITEST TICKED GREEN IS NEVER WHY A SHIP FAILED (w-c121bd85e6).
|
|
106
|
+
//
|
|
107
|
+
// Test files here are named after the behaviour they cover, so plenty of them
|
|
108
|
+
// carry "failed", "error" or "refused" inside the name, with a hyphen either
|
|
109
|
+
// side. A hyphen is a word boundary, so even the whole-word guard below
|
|
110
|
+
// matches inside "a-failed-hook-is-visible-while-the-agent-continues". On
|
|
111
|
+
// 2026-10-04 a ship that DIED was reported as that file, which had passed in
|
|
112
|
+
// 51 milliseconds. Vitest ticks every file it ran green, so the tick is the
|
|
113
|
+
// one reliable way to rule a line out.
|
|
114
|
+
const TICKED_GREEN = /^[✓✔]/;
|
|
115
|
+
// WHOLE WORDS (w-7ec8553e23): without \b, "red" matched inside "configured"
|
|
116
|
+
// and a harmless warning the tests print was shown as why a ship failed.
|
|
117
|
+
const NAMES_A_FAILURE = /\b(CONFLICT|error|red|refused|failed|FAIL)\b/i;
|
|
118
|
+
// Vitest's heading over the list of failures, "⎯⎯⎯ Failed Tests 3 ⎯⎯⎯". It
|
|
119
|
+
// carries the count and names nothing, and the FAIL lines that DO name
|
|
120
|
+
// something are printed directly under it, so taking the first match showed
|
|
121
|
+
// the heading and sent whoever read it to the GitHub run to find out which
|
|
122
|
+
// three (w-c121bd85e6, this branch's own first ship). Passed over while there
|
|
123
|
+
// is anything better, and still offered when it is all there is.
|
|
124
|
+
const ONLY_A_COUNT = /^⎯+ .* ⎯+$/;
|
|
125
|
+
// A LINE VITEST QUOTED IS NOT THE SCRIPT SPEAKING (w-560647d4db, five
|
|
126
|
+
// bounces). A failing text assertion prints the file's own text as a diff, so
|
|
127
|
+
// any source line carrying one of those words becomes a candidate — and the
|
|
128
|
+
// diff is printed long before the runner's list of failures. Twice the note
|
|
129
|
+
// read "+ than deleted: a bare corner here reads as a control
|
|
130
|
+
// that failed", which is a pre-existing code comment in a file the branch
|
|
131
|
+
// touched. It names no fault, and the real cause was three failing tests the
|
|
132
|
+
// note never mentioned. Both shapes of quotation are skipped: a diff line
|
|
133
|
+
// (`+`/`-`) and a code frame (`34| expect(...)`).
|
|
134
|
+
const QUOTED_SOURCE = /^([+-]|\d+\|)/;
|
|
135
|
+
// THE RUNNER'S OWN FAILURES OUTRANK A LOOSE WORD MATCH. These two name a
|
|
136
|
+
// file: `FAIL tests/x.test.mjs > ...` and `❯ tests/x.test.mjs (3 tests | 3
|
|
137
|
+
// failed)`. Anything else matching the words above — the script's closing
|
|
138
|
+
// line, a merge message, a warning — says less, wherever it sits in the
|
|
139
|
+
// output.
|
|
140
|
+
const NAMES_A_RED_TEST = /^(FAIL\b|❯ .*\|\s*\d+ failed)/;
|
|
141
|
+
// Vitest's totals, "Tests 3 failed | 1920 passed (1923)", and its heading
|
|
142
|
+
// "⎯⎯⎯ Failed Tests 3 ⎯⎯⎯". Not "Test Files 1 failed", which counts files.
|
|
143
|
+
const HOW_MANY = [/^Tests\s+(\d+) failed/, /Failed Tests\s+(\d+)/];
|
|
144
|
+
|
|
145
|
+
const DIED = 'the test run died without naming a failing test';
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Why the branch did not ship, out of the script's own output.
|
|
149
|
+
*
|
|
150
|
+
* A run that went RED says so somewhere. A run that DIED — killed on a busy
|
|
151
|
+
* Mac, or cut off — leaves output with no failure in it at all, and the two
|
|
152
|
+
* have to be told apart: offering the nearest line instead sends whoever
|
|
153
|
+
* reads it after a fault that is not there.
|
|
154
|
+
*
|
|
155
|
+
* @returns {{ died: boolean, why: string|null, last: string|null, failed: number|null }}
|
|
156
|
+
*/
|
|
157
|
+
export function whyItDidNotShip(output) {
|
|
158
|
+
const lines = String(output ?? '').split('\n').map((l) => l.trim()).filter(Boolean);
|
|
159
|
+
const said = lines.filter((l) => !TICKED_GREEN.test(l) && !QUOTED_SOURCE.test(l));
|
|
160
|
+
const named = said.filter((l) => NAMES_A_FAILURE.test(l));
|
|
161
|
+
const red = named.find((l) => NAMES_A_RED_TEST.test(l));
|
|
162
|
+
const why = red ?? named.find((l) => !ONLY_A_COUNT.test(l)) ?? named[0] ?? null;
|
|
163
|
+
// The count goes with a line that names ONE test, so that it reads as the
|
|
164
|
+
// first of several rather than as the whole story. On a line that is not a
|
|
165
|
+
// test — a conflict, a refused push — a number beside it would read as that
|
|
166
|
+
// line's own count, so it is left off.
|
|
167
|
+
const counted = red ? Number(said.map((l) => HOW_MANY.map((re) => re.exec(l)?.[1]).find(Boolean)).find(Boolean)) : NaN;
|
|
168
|
+
return { died: !why, why, last: said.at(-1) ?? null, failed: Number.isFinite(counted) ? counted : null };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** "3 tests failed, the first is FAIL tests/…", or just the line. */
|
|
172
|
+
function diagnosis({ why, failed }) {
|
|
173
|
+
if (!why || !failed) return why;
|
|
174
|
+
return `${failed} test${failed === 1 ? '' : 's'} failed, the first is ${why}`;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** What the agent is told when its branch did not ship. */
|
|
178
|
+
export function failureReply(output) {
|
|
179
|
+
const tail = String(output ?? '').trim().slice(-KEEP);
|
|
180
|
+
const { died, ...rest } = whyItDidNotShip(output);
|
|
181
|
+
return [
|
|
182
|
+
`${Name} tried to ship your branch and it did not go out. Nothing was pushed.`,
|
|
183
|
+
// THE SAME DIAGNOSIS THE THREAD SHOWS, above the tail (w-560647d4db). The
|
|
184
|
+
// tail holds everything, which is why two sessions read a quoted code
|
|
185
|
+
// comment in it as the fault. One line naming how many tests failed and
|
|
186
|
+
// which file is where to start.
|
|
187
|
+
...(died ? [] : ['', `It went red: ${diagnosis(rest)}`]),
|
|
188
|
+
...(died ? [
|
|
189
|
+
'',
|
|
190
|
+
'Its test run DIED rather than going red: nothing it printed names a failing',
|
|
191
|
+
'test. Look for what killed the run. This computer runs a dozen agents at once, so',
|
|
192
|
+
'a worker that was starved or timed out is likelier than a bug in your change.',
|
|
193
|
+
'Run the tests for your change again before you go looking for one.',
|
|
194
|
+
] : []),
|
|
195
|
+
'',
|
|
196
|
+
'Fix what the script says below in your task folder, commit, then add the label',
|
|
197
|
+
`\`${SHIP_LABEL}\` to this row again and end your turn. Do not push or touch any`,
|
|
198
|
+
'other folder yourself; the app ships it.',
|
|
199
|
+
'',
|
|
200
|
+
'```',
|
|
201
|
+
tail,
|
|
202
|
+
'```',
|
|
203
|
+
].join('\n');
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** The line the thread shows: the app's own, short, and the first error in it. */
|
|
207
|
+
export function failureNote(output) {
|
|
208
|
+
const { died, last, ...rest } = whyItDidNotShip(output);
|
|
209
|
+
const said = died ? `${DIED}. It last said: ${last ?? 'nothing at all'}` : diagnosis(rest);
|
|
210
|
+
return `It did not ship, and it went back to its agent: ${said.slice(0, 300)}`;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export function runScript(script, cwd, { timeout = SHIP_MS, env = process.env } = {}) {
|
|
214
|
+
const node = env.npm_node_execpath && fs.existsSync(env.npm_node_execpath) ? env.npm_node_execpath : 'node';
|
|
215
|
+
return new Promise((resolve) => {
|
|
216
|
+
let out = '';
|
|
217
|
+
let child;
|
|
218
|
+
try {
|
|
219
|
+
child = spawn(node, [script], { cwd, env: { ...env, GIT_TERMINAL_PROMPT: '0' }, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
220
|
+
} catch (e) {
|
|
221
|
+
resolve({ code: 1, out: String(e?.message ?? e) });
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
const keep = (b) => { out = (out + b).slice(-64 * 1024); };
|
|
225
|
+
child.stdout.on('data', keep);
|
|
226
|
+
child.stderr.on('data', keep);
|
|
227
|
+
const timer = setTimeout(() => { keep('\nship: stopped after 20 minutes.'); child.kill('SIGTERM'); }, timeout);
|
|
228
|
+
child.on('error', (e) => keep(`\n${e.message}`));
|
|
229
|
+
child.on('close', (code) => { clearTimeout(timer); resolve({ code: code ?? 1, out }); });
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* One task at a time, for the whole Mac. `tick` is called from the
|
|
235
|
+
* supervisor's own tick and returns at once; the run happens beside it.
|
|
236
|
+
*/
|
|
237
|
+
export class ShipQueue {
|
|
238
|
+
constructor({ store, userDir, folderFor, isLive, handBack = () => {}, run = runScript, afterShip = () => {}, log = console }) {
|
|
239
|
+
Object.assign(this, { store, userDir, folderFor, isLive, handBack, run, afterShip, log });
|
|
240
|
+
this.busy = null;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
tick(items, products) {
|
|
244
|
+
if (this.busy) return null;
|
|
245
|
+
const settings = readShipSettings(this.userDir);
|
|
246
|
+
if (!Object.keys(settings).length) return null;
|
|
247
|
+
const next = nextToShip(items, products, settings, { isLive: this.isLive, folderFor: this.folderFor, handled: readHandled(this.userDir) });
|
|
248
|
+
if (!next) return null;
|
|
249
|
+
// Marked handled only once the run has ENDED, pass or fail. Marked before
|
|
250
|
+
// it, a run cut off by an app restart was never tried again
|
|
251
|
+
// (w-f37a34def6, 2026-10-04). Within one app, `busy` already stops a
|
|
252
|
+
// second start; across a restart, trying again is the point.
|
|
253
|
+
const done = () => writeHandled(this.userDir, { ...readHandled(this.userDir), [keyOf(next.item)]: markedAt(next.item) });
|
|
254
|
+
this.busy = this.ship(next).then(done, done).finally(() => { this.busy = null; });
|
|
255
|
+
return this.busy;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
async ship({ item, product, script, cwd }) {
|
|
259
|
+
this.log.info?.(`zero: shipping ${item.id} from ${cwd}`);
|
|
260
|
+
const { code, out } = await this.run(script, cwd);
|
|
261
|
+
try {
|
|
262
|
+
// Already on main: there is nothing to fix, so nobody is woken.
|
|
263
|
+
const already = /There is nothing to ship/.test(out);
|
|
264
|
+
if (code === 0 || already) {
|
|
265
|
+
this.store.shipped(product.slug, item.id, { sha: shippedSha(out), labels: withoutShipLabel(item) });
|
|
266
|
+
this.afterShip();
|
|
267
|
+
} else {
|
|
268
|
+
this.store.shipFailed(product.slug, item.id, { note: failureNote(out), labels: withoutShipLabel(item) });
|
|
269
|
+
// Straight back to the session that wrote the change. Not through the
|
|
270
|
+
// reply on the row: that field is hers, and the app's words written
|
|
271
|
+
// there would be drawn as hers and could not replace hers anyway.
|
|
272
|
+
this.handBack(item, failureReply(out));
|
|
273
|
+
}
|
|
274
|
+
} catch (e) {
|
|
275
|
+
this.log.warn?.(`zero: could not record the ship of ${item.id}:`, e.message);
|
|
276
|
+
}
|
|
277
|
+
return { code };
|
|
278
|
+
}
|
|
279
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// THE FILES A LOGIN WRITES, SO THE APP CAN SEE ONE LAND.
|
|
2
|
+
//
|
|
3
|
+
// A signed-out account used to sit out a fixed half hour, and nothing but a
|
|
4
|
+
// session surviving on it could bring it back, which no session could do while
|
|
5
|
+
// it sat out. So typing /login fixed nothing a person could see: their agents
|
|
6
|
+
// stayed "Queued" until the half hour ran out on its own, and a /login and any
|
|
7
|
+
// "continue" replies in between did nothing.
|
|
8
|
+
//
|
|
9
|
+
// A login rewrites these. Only their modification times are read, never their
|
|
10
|
+
// contents: what is in them is her credentials and is none of our business.
|
|
11
|
+
// A moved time is a reason to TRY again, not proof; the try is the proof.
|
|
12
|
+
|
|
13
|
+
import fs from 'node:fs';
|
|
14
|
+
import path from 'node:path';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Where one account's login is written.
|
|
18
|
+
* - Claude Code, its default login: `~/.claude.json` (where the CLI keeps the
|
|
19
|
+
* account it is signed in as) and the same files inside `~/.claude`.
|
|
20
|
+
* - Claude Code, a CLAUDE_CONFIG_DIR: those files inside that folder.
|
|
21
|
+
* - Codex: `auth.json` inside its CODEX_HOME.
|
|
22
|
+
*/
|
|
23
|
+
export function signInFiles({ engine = 'claude', folder = null, home }) {
|
|
24
|
+
if (engine === 'codex') return folder ? [path.join(folder, 'auth.json')] : [];
|
|
25
|
+
if (!folder) {
|
|
26
|
+
const dir = path.join(home, '.claude');
|
|
27
|
+
return [path.join(home, '.claude.json'), path.join(dir, '.claude.json'), path.join(dir, '.credentials.json')];
|
|
28
|
+
}
|
|
29
|
+
return [path.join(folder, '.claude.json'), path.join(folder, '.credentials.json')];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The newest modification time among them, or 0 when none can be read. */
|
|
33
|
+
export function signInStamp(files, stat = fs.statSync) {
|
|
34
|
+
let newest = 0;
|
|
35
|
+
for (const f of files ?? []) {
|
|
36
|
+
try { newest = Math.max(newest, stat(f).mtimeMs); } catch {}
|
|
37
|
+
}
|
|
38
|
+
return newest;
|
|
39
|
+
}
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
// A COPY RUN FROM SOURCE KEEPS ITSELF CURRENT TOO (w-7a39dace23, 2026-10-01).
|
|
2
|
+
//
|
|
3
|
+
// Teammates run the team build from a git checkout (docs/team/PLAN.md), and
|
|
4
|
+
// main/updater.mjs cannot help them: electron-updater reads published releases
|
|
5
|
+
// and does nothing at all unpackaged. Their updates are pushes to main, several
|
|
6
|
+
// a day, so this watches the branch instead of a release feed.
|
|
7
|
+
//
|
|
8
|
+
// WHAT IT DOES. A few seconds after launch, and then every half hour, it runs
|
|
9
|
+
// `git fetch` and asks whether the branch's upstream is ahead of the code this
|
|
10
|
+
// process booted from. If it is, the state goes to `ready` and the sidebar
|
|
11
|
+
// shows the "New version ready" card (renderer/src/components/SidebarUpdate.tsx),
|
|
12
|
+
// which names what changed when pointed at.
|
|
13
|
+
// Pressing Restart fast-forwards the folder, reinstalls packages only when
|
|
14
|
+
// package.json or the lockfile changed, rebuilds the screens, and relaunches.
|
|
15
|
+
// Nothing happens until that button is pressed.
|
|
16
|
+
//
|
|
17
|
+
// IT NEVER TOUCHES WORK SOMEBODY HAS NOT PUSHED. Four cases get no offer at
|
|
18
|
+
// all, each stated in `error` so Settings can say why: a folder that is not a
|
|
19
|
+
// checkout, a branch that follows nothing, tracked files with edits in them,
|
|
20
|
+
// and a branch with commits of its own. The founder's own app runs from a
|
|
21
|
+
// local branch with no upstream, so it is untouched by this. Untracked files
|
|
22
|
+
// (zero.config.json, cloud/team.config.json) do not count as edits: they are
|
|
23
|
+
// ignored or new, and a fast-forward cannot overwrite them without failing.
|
|
24
|
+
//
|
|
25
|
+
// "AHEAD OF WHAT IS RUNNING", NOT "AHEAD OF THE FOLDER". The code in memory is
|
|
26
|
+
// what booted, so the folder brought up to date by hand without a restart is
|
|
27
|
+
// still an update waiting, and the row says so.
|
|
28
|
+
//
|
|
29
|
+
// IF THE BUILD FAILS IT DOES NOT RESTART. The row comes back with what went
|
|
30
|
+
// wrong, and pressing Restart again retries from where it stopped. Restarting
|
|
31
|
+
// onto screens that did not build would be the one outcome worse than waiting.
|
|
32
|
+
|
|
33
|
+
import { execFile, execFileSync, spawn } from 'node:child_process';
|
|
34
|
+
import fs from 'node:fs';
|
|
35
|
+
import { describe } from './updater.mjs';
|
|
36
|
+
|
|
37
|
+
const FIRST_CHECK_MS = 20_000;
|
|
38
|
+
const EVERY_MS = 30 * 60 * 1000;
|
|
39
|
+
const FETCH_MS = 60_000;
|
|
40
|
+
const INSTALL_MS = 10 * 60 * 1000;
|
|
41
|
+
const BUILD_MS = 5 * 60 * 1000;
|
|
42
|
+
// How many change titles the row lists. The rest are counted, not named.
|
|
43
|
+
const LISTED = 5;
|
|
44
|
+
|
|
45
|
+
const SAY = {
|
|
46
|
+
notRepo: 'This folder is not a git checkout, so it does not update itself.',
|
|
47
|
+
noUpstream: 'This copy\'s branch does not follow a branch on GitHub, so it does not update itself.',
|
|
48
|
+
dirty: 'This folder has changes of your own in it, so it does not update itself.',
|
|
49
|
+
diverged: 'This copy has commits of its own, so it does not update itself.',
|
|
50
|
+
offline: 'Could not reach GitHub.',
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
// A FILE THE BUILD WRITES IS NOT AN EDIT OF HERS (2026-10-02). `npm run build`
|
|
54
|
+
// rewrites shared/*.generated.mjs with the version and date of the Claude Code
|
|
55
|
+
// on this Mac, and the restart below runs that build. Counted as her changes,
|
|
56
|
+
// they made every update after the first say "changes of your own" for good
|
|
57
|
+
// (tests/a-build-does-not-stop-the-next-update.test.mjs).
|
|
58
|
+
const isBuildOutput = (file) => /^shared\/[^/]+\.generated\.[cm]?[jt]s$/.test(file);
|
|
59
|
+
// The status code is one or two letters and `gitIn` trims the output, which
|
|
60
|
+
// takes the leading space off the first line, so it is matched, not sliced.
|
|
61
|
+
const changedPaths = (porcelain) => String(porcelain ?? '').split('\n').filter(Boolean).map((l) => l.replace(/^[ MADRCUT?!]{1,2} /, '').replace(/^"|"$/g, ''));
|
|
62
|
+
const theirEdits = (porcelain) => changedPaths(porcelain).filter((f) => !isBuildOutput(f));
|
|
63
|
+
|
|
64
|
+
function gitIn(appDir) {
|
|
65
|
+
return (args, { timeout = 30_000 } = {}) => new Promise((resolve) => {
|
|
66
|
+
execFile('git', args, {
|
|
67
|
+
cwd: appDir,
|
|
68
|
+
timeout,
|
|
69
|
+
// Never a password prompt from a background check. The repository is
|
|
70
|
+
// public; anything that asks for credentials is a failure to report.
|
|
71
|
+
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
|
|
72
|
+
maxBuffer: 4 * 1024 * 1024,
|
|
73
|
+
}, (err, stdout, stderr) => {
|
|
74
|
+
resolve({ code: err ? (typeof err.code === 'number' ? err.code : 1) : 0, out: String(stdout).trim(), err: String(stderr).trim() });
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function collect(bin, args, { cwd, timeout }) {
|
|
80
|
+
return new Promise((resolve) => {
|
|
81
|
+
let out = '';
|
|
82
|
+
let child;
|
|
83
|
+
try {
|
|
84
|
+
child = spawn(bin, args, { cwd, env: process.env, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
85
|
+
} catch (e) {
|
|
86
|
+
resolve({ code: 1, out: String(e?.message ?? e) });
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
const keep = (b) => { out = (out + b).slice(-64 * 1024); };
|
|
90
|
+
child.stdout.on('data', keep);
|
|
91
|
+
child.stderr.on('data', keep);
|
|
92
|
+
const timer = setTimeout(() => { keep('\nerror: timed out'); child.kill('SIGTERM'); }, timeout);
|
|
93
|
+
child.on('error', (e) => { keep(`\nerror: ${e.message}`); });
|
|
94
|
+
child.on('close', (code) => { clearTimeout(timer); resolve({ code: code ?? 1, out }); });
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// WHICH npm. Started with `npm start`, the process carries npm's own path in
|
|
99
|
+
// its environment, so that one is used and nvm or a version manager never
|
|
100
|
+
// matters. Started any other way, her login shell is asked, the way
|
|
101
|
+
// main/claude-bin.mjs finds Claude Code. The arguments are fixed strings from
|
|
102
|
+
// this file, never anything read from disk.
|
|
103
|
+
export function defaultNpm(appDir, env = process.env) {
|
|
104
|
+
return (args) => {
|
|
105
|
+
const timeout = args[0] === 'install' ? INSTALL_MS : BUILD_MS;
|
|
106
|
+
const node = env.npm_node_execpath;
|
|
107
|
+
const cli = env.npm_execpath;
|
|
108
|
+
if (node && cli && fs.existsSync(node) && fs.existsSync(cli)) {
|
|
109
|
+
return collect(node, [cli, ...args], { cwd: appDir, timeout });
|
|
110
|
+
}
|
|
111
|
+
const shell = env.SHELL || '/bin/zsh';
|
|
112
|
+
return collect(shell, ['-lic', `npm ${args.join(' ')}`], { cwd: appDir, timeout });
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// The line of a failed build worth showing a person: the first that says
|
|
117
|
+
// error, else the last thing it printed.
|
|
118
|
+
export function failureLine(out) {
|
|
119
|
+
const lines = String(out ?? '').split('\n').map((l) => l.trim()).filter(Boolean);
|
|
120
|
+
const named = lines.find((l) => /error/i.test(l));
|
|
121
|
+
const line = named ?? lines[lines.length - 1] ?? 'it stopped without saying why';
|
|
122
|
+
return line.length > 160 ? `${line.slice(0, 157)}…` : line;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function createSourceUpdater({
|
|
126
|
+
appDir,
|
|
127
|
+
app = null,
|
|
128
|
+
log = console,
|
|
129
|
+
onChanged = () => {},
|
|
130
|
+
npm = defaultNpm(appDir),
|
|
131
|
+
relaunch = () => { app?.relaunch(); app?.quit(); },
|
|
132
|
+
} = {}) {
|
|
133
|
+
const git = gitIn(appDir);
|
|
134
|
+
// The commit this process booted from. Read once, now, because it is the
|
|
135
|
+
// code in memory and nothing on disk can change that.
|
|
136
|
+
let bootHead = null;
|
|
137
|
+
try {
|
|
138
|
+
bootHead = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: appDir, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim() || null;
|
|
139
|
+
} catch { /* not a checkout; check() says so */ }
|
|
140
|
+
|
|
141
|
+
let state = {
|
|
142
|
+
phase: 'idle',
|
|
143
|
+
source: true,
|
|
144
|
+
currentVersion: bootHead ? bootHead.slice(0, 7) : null,
|
|
145
|
+
newVersion: null,
|
|
146
|
+
percent: null,
|
|
147
|
+
error: null,
|
|
148
|
+
checkedAt: null,
|
|
149
|
+
readyAt: null,
|
|
150
|
+
changes: [],
|
|
151
|
+
behind: null,
|
|
152
|
+
};
|
|
153
|
+
let timer = null;
|
|
154
|
+
let stopped = false;
|
|
155
|
+
let pending = Promise.resolve();
|
|
156
|
+
|
|
157
|
+
const set = (next) => {
|
|
158
|
+
state = { ...state, ...next };
|
|
159
|
+
try { onChanged(describe(state)); } catch {}
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
// Everything that has to be true before an update is offered or applied.
|
|
163
|
+
// Returns the upstream commit, or the sentence saying why not.
|
|
164
|
+
async function preconditions() {
|
|
165
|
+
if (!bootHead) return { why: SAY.notRepo };
|
|
166
|
+
const upstream = await git(['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}']);
|
|
167
|
+
if (upstream.code !== 0) return { why: SAY.noUpstream };
|
|
168
|
+
const dirty = await git(['status', '--porcelain', '--untracked-files=no']);
|
|
169
|
+
if (dirty.code !== 0 || theirEdits(dirty.out).length) return { why: SAY.dirty };
|
|
170
|
+
const ahead = await git(['merge-base', '--is-ancestor', 'HEAD', '@{u}']);
|
|
171
|
+
if (ahead.code !== 0) return { why: SAY.diverged };
|
|
172
|
+
const up = await git(['rev-parse', '@{u}']);
|
|
173
|
+
if (up.code !== 0 || !up.out) return { why: SAY.noUpstream };
|
|
174
|
+
return { up: up.out };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async function check() {
|
|
178
|
+
if (stopped) return describe(state);
|
|
179
|
+
if (state.phase === 'checking' || state.phase === 'installing') return describe(state);
|
|
180
|
+
if (!bootHead) {
|
|
181
|
+
set({ phase: 'unsupported', error: SAY.notRepo, checkedAt: Date.now() });
|
|
182
|
+
return describe(state);
|
|
183
|
+
}
|
|
184
|
+
const was = state;
|
|
185
|
+
set({ phase: 'checking' });
|
|
186
|
+
// A failed fetch is not the end of the check: what was fetched before is
|
|
187
|
+
// still on disk, and an update already found stays offered on a plane.
|
|
188
|
+
const fetched = await git(['fetch', '--quiet'], { timeout: FETCH_MS });
|
|
189
|
+
if (fetched.code !== 0) log.warn?.('[source-updater] fetch failed:', fetched.err.split('\n')[0]);
|
|
190
|
+
const ok = await preconditions();
|
|
191
|
+
if (ok.why) {
|
|
192
|
+
set({ phase: 'unsupported', error: ok.why, newVersion: null, changes: [], behind: null, readyAt: null, checkedAt: Date.now() });
|
|
193
|
+
return describe(state);
|
|
194
|
+
}
|
|
195
|
+
if (ok.up === bootHead) {
|
|
196
|
+
set(fetched.code === 0
|
|
197
|
+
? { phase: 'current', error: null, newVersion: null, changes: [], behind: null, readyAt: null, checkedAt: Date.now() }
|
|
198
|
+
: { phase: 'error', error: SAY.offline, checkedAt: Date.now() });
|
|
199
|
+
return describe(state);
|
|
200
|
+
}
|
|
201
|
+
const range = `${bootHead}..${ok.up}`;
|
|
202
|
+
const titles = await git(['log', '--no-merges', '--format=%s', `-n${LISTED}`, range]);
|
|
203
|
+
const count = await git(['rev-list', '--no-merges', '--count', range]);
|
|
204
|
+
const newVersion = ok.up.slice(0, 7);
|
|
205
|
+
set({
|
|
206
|
+
phase: 'ready',
|
|
207
|
+
newVersion,
|
|
208
|
+
changes: titles.code === 0 && titles.out ? titles.out.split('\n') : [],
|
|
209
|
+
behind: count.code === 0 ? Number(count.out) || 0 : null,
|
|
210
|
+
// Stamped when THIS version was first seen, so a recheck does not make
|
|
211
|
+
// old news young again, and a newer push does.
|
|
212
|
+
readyAt: was.phase === 'ready' && was.newVersion === newVersion && was.readyAt ? was.readyAt : Date.now(),
|
|
213
|
+
// A failed earlier restart keeps saying so until the next press.
|
|
214
|
+
error: was.phase === 'ready' && was.newVersion === newVersion ? was.error : null,
|
|
215
|
+
checkedAt: Date.now(),
|
|
216
|
+
});
|
|
217
|
+
return describe(state);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
async function apply() {
|
|
221
|
+
const ok = await preconditions();
|
|
222
|
+
if (ok.why) {
|
|
223
|
+
set({ phase: 'unsupported', error: ok.why, newVersion: null, changes: [], behind: null, readyAt: null });
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
const head = await git(['rev-parse', 'HEAD']);
|
|
227
|
+
// What the last build stamped goes back first, so the fast-forward can
|
|
228
|
+
// never trip on it. The build below writes it again.
|
|
229
|
+
const stamped = await git(['status', '--porcelain', '--untracked-files=no']);
|
|
230
|
+
const buildOutput = stamped.code === 0 ? changedPaths(stamped.out).filter(isBuildOutput) : [];
|
|
231
|
+
if (buildOutput.length) await git(['checkout', '--', ...buildOutput]);
|
|
232
|
+
if (head.out !== ok.up) {
|
|
233
|
+
const merged = await git(['merge', '--ff-only', '--quiet', '@{u}']);
|
|
234
|
+
if (merged.code !== 0) {
|
|
235
|
+
set({ phase: 'ready', error: `Could not bring the new code in: ${failureLine(merged.err || merged.out)}` });
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
// Packages changed when either file's object id moved. Compared by id
|
|
240
|
+
// rather than by asking git for a diff, which this process does in exactly
|
|
241
|
+
// one file (tests/the-old-git-diff-screen-stays-gone.test.mjs).
|
|
242
|
+
let depsMoved = false;
|
|
243
|
+
for (const file of ['package.json', 'package-lock.json']) {
|
|
244
|
+
const was = await git(['rev-parse', '--verify', '--quiet', `${bootHead}:${file}`]);
|
|
245
|
+
const now = await git(['rev-parse', '--verify', '--quiet', `HEAD:${file}`]);
|
|
246
|
+
if (was.out !== now.out) depsMoved = true;
|
|
247
|
+
}
|
|
248
|
+
if (depsMoved) {
|
|
249
|
+
const installed = await npm(['install', '--no-audit', '--no-fund']);
|
|
250
|
+
if (installed.code !== 0) {
|
|
251
|
+
set({ phase: 'ready', error: `The new packages would not install: ${failureLine(installed.out)}` });
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
const built = await npm(['run', 'build']);
|
|
256
|
+
if (built.code !== 0) {
|
|
257
|
+
set({ phase: 'ready', error: `The new code would not build: ${failureLine(built.out)}` });
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
log.info?.('[source-updater] built', state.newVersion, '- relaunching');
|
|
261
|
+
relaunch();
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// THE BUTTON. Returns at once; the work takes about a minute and the row
|
|
265
|
+
// says "Updating" while it runs.
|
|
266
|
+
function install() {
|
|
267
|
+
if (stopped || state.phase !== 'ready') return false;
|
|
268
|
+
set({ phase: 'installing', error: null });
|
|
269
|
+
pending = apply().catch((e) => {
|
|
270
|
+
set({ phase: 'ready', error: `The update stopped: ${failureLine(e?.message ?? e)}` });
|
|
271
|
+
});
|
|
272
|
+
return true;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function start() {
|
|
276
|
+
if (stopped || timer) return;
|
|
277
|
+
const tick = () => { void check(); };
|
|
278
|
+
setTimeout(tick, FIRST_CHECK_MS).unref?.();
|
|
279
|
+
timer = setInterval(tick, EVERY_MS);
|
|
280
|
+
timer.unref?.();
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function stop() {
|
|
284
|
+
stopped = true;
|
|
285
|
+
if (timer) clearInterval(timer);
|
|
286
|
+
timer = null;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
return {
|
|
290
|
+
start,
|
|
291
|
+
stop,
|
|
292
|
+
check: () => check(),
|
|
293
|
+
install,
|
|
294
|
+
state: () => describe(state),
|
|
295
|
+
// For tests: resolves when a started install has finished, either way.
|
|
296
|
+
settled: () => pending,
|
|
297
|
+
};
|
|
298
|
+
}
|