@bongos/core 1.19.75 → 1.19.77
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/.bongos-core.json +20 -15
- package/docs/canonical-permissions.md +9 -0
- package/docs/design/projects-surfaces-redesign-direction.md +157 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/box.js +28 -14
- package/scripts/gds/hooks/pre-push.js +45 -11
- package/src/module-api.js +5 -1
- package/tests/boxes.mjs +81 -0
- package/tests/pre_push_hook.mjs +103 -6
package/.bongos-core.json
CHANGED
|
@@ -2,22 +2,22 @@
|
|
|
2
2
|
"artifact": "bongos-core",
|
|
3
3
|
"manifest_schema": 1,
|
|
4
4
|
"generator": "scripts/gds/package-core.js",
|
|
5
|
-
"core_version": "1.19.
|
|
6
|
-
"core_contract": "1.19.
|
|
7
|
-
"source_commit": "
|
|
5
|
+
"core_version": "1.19.77",
|
|
6
|
+
"core_contract": "1.19.77",
|
|
7
|
+
"source_commit": "a20aad3a45d55f5a39b9923d17ec460bfd504630",
|
|
8
8
|
"source_ref": "HEAD",
|
|
9
|
-
"built_at": "2026-08-
|
|
9
|
+
"built_at": "2026-08-12T22:40:06.341Z",
|
|
10
10
|
"redaction": {
|
|
11
11
|
"model": "docs-redacted+functional-verbatim",
|
|
12
|
-
"docs_redacted":
|
|
12
|
+
"docs_redacted": 295,
|
|
13
13
|
"agent_docs_stubbed": 21,
|
|
14
14
|
"functional_verbatim": 1225,
|
|
15
15
|
"rules": 3,
|
|
16
16
|
"gate_literals": 3,
|
|
17
17
|
"gate": "passed"
|
|
18
18
|
},
|
|
19
|
-
"file_count":
|
|
20
|
-
"tree_sha256": "
|
|
19
|
+
"file_count": 1541,
|
|
20
|
+
"tree_sha256": "3125b6aac967f4e3e3e323f3572eee2b53307f8f0acfb7699a40202bc966e354",
|
|
21
21
|
"files": [
|
|
22
22
|
{
|
|
23
23
|
"path": ".claude/skills/blocker-review/SKILL.md",
|
|
@@ -1347,7 +1347,7 @@
|
|
|
1347
1347
|
{
|
|
1348
1348
|
"path": "docs/canonical-permissions.md",
|
|
1349
1349
|
"mode": "0000644",
|
|
1350
|
-
"sha256": "
|
|
1350
|
+
"sha256": "1e1b93e51e8408ae8b44d3d241f4fe52babefa59c03d36aced5b04ffa745dce2"
|
|
1351
1351
|
},
|
|
1352
1352
|
{
|
|
1353
1353
|
"path": "docs/design-contract.md",
|
|
@@ -1384,6 +1384,11 @@
|
|
|
1384
1384
|
"mode": "0000644",
|
|
1385
1385
|
"sha256": "8a8430af4bbf082d3bab28d5b82b353a6921782014c2852a87eb1056ba101722"
|
|
1386
1386
|
},
|
|
1387
|
+
{
|
|
1388
|
+
"path": "docs/design/projects-surfaces-redesign-direction.md",
|
|
1389
|
+
"mode": "0000644",
|
|
1390
|
+
"sha256": "90babf214f3ef90e44943af2dcb129aa2b8cfb9326d1f8cfdd19768db3fd6715"
|
|
1391
|
+
},
|
|
1387
1392
|
{
|
|
1388
1393
|
"path": "docs/design/vanilla-hall-ui-redesign-scope.md",
|
|
1389
1394
|
"mode": "0000644",
|
|
@@ -4052,12 +4057,12 @@
|
|
|
4052
4057
|
{
|
|
4053
4058
|
"path": "package-lock.json",
|
|
4054
4059
|
"mode": "0000644",
|
|
4055
|
-
"sha256": "
|
|
4060
|
+
"sha256": "ffd10996e222dd7fe49aa05ad7177979e18cb7871ee3666f2cc96c9822e8075e"
|
|
4056
4061
|
},
|
|
4057
4062
|
{
|
|
4058
4063
|
"path": "package.json",
|
|
4059
4064
|
"mode": "0000644",
|
|
4060
|
-
"sha256": "
|
|
4065
|
+
"sha256": "3cdd1266637cfb67eed301d96a9f6414d32d98f1d4e9033e20aa6687806526c3"
|
|
4061
4066
|
},
|
|
4062
4067
|
{
|
|
4063
4068
|
"path": "public-docs/index.html",
|
|
@@ -4552,7 +4557,7 @@
|
|
|
4552
4557
|
{
|
|
4553
4558
|
"path": "scripts/gds/box.js",
|
|
4554
4559
|
"mode": "0000644",
|
|
4555
|
-
"sha256": "
|
|
4560
|
+
"sha256": "41e1a0c0ef56d5cf10cc73124964c114854a12d8dd3de249d4bafcda04dd2106"
|
|
4556
4561
|
},
|
|
4557
4562
|
{
|
|
4558
4563
|
"path": "scripts/gds/build-bongos.js",
|
|
@@ -4832,7 +4837,7 @@
|
|
|
4832
4837
|
{
|
|
4833
4838
|
"path": "scripts/gds/hooks/pre-push.js",
|
|
4834
4839
|
"mode": "0000644",
|
|
4835
|
-
"sha256": "
|
|
4840
|
+
"sha256": "682c19e53e0633f02d5d5c2d4c9e3ae7c04e304c32461ed210b5fad8a6623e33"
|
|
4836
4841
|
},
|
|
4837
4842
|
{
|
|
4838
4843
|
"path": "scripts/gds/http-api-client.js",
|
|
@@ -5682,7 +5687,7 @@
|
|
|
5682
5687
|
{
|
|
5683
5688
|
"path": "src/module-api.js",
|
|
5684
5689
|
"mode": "0000644",
|
|
5685
|
-
"sha256": "
|
|
5690
|
+
"sha256": "fee8a499a0208f8f825a317e5ab631684bcb2c718dde601e6eab23eda5f2728c"
|
|
5686
5691
|
},
|
|
5687
5692
|
{
|
|
5688
5693
|
"path": "src/module-loader/loader.js",
|
|
@@ -6037,7 +6042,7 @@
|
|
|
6037
6042
|
{
|
|
6038
6043
|
"path": "tests/boxes.mjs",
|
|
6039
6044
|
"mode": "0000644",
|
|
6040
|
-
"sha256": "
|
|
6045
|
+
"sha256": "8702916e37e8cc08ae0e055b1bc7ea70da71720c51bd2a05df2a4dc598a17f97"
|
|
6041
6046
|
},
|
|
6042
6047
|
{
|
|
6043
6048
|
"path": "tests/branding.mjs",
|
|
@@ -7152,7 +7157,7 @@
|
|
|
7152
7157
|
{
|
|
7153
7158
|
"path": "tests/pre_push_hook.mjs",
|
|
7154
7159
|
"mode": "0000644",
|
|
7155
|
-
"sha256": "
|
|
7160
|
+
"sha256": "59716c99719a4e9f5475757f48331c3fb9f3edc58b204b4f3d40abe0b39937db"
|
|
7156
7161
|
},
|
|
7157
7162
|
{
|
|
7158
7163
|
"path": "tests/preflight_checks.mjs",
|
|
@@ -274,6 +274,15 @@ pre-merge gate misses, independent of the builder's cooperation:
|
|
|
274
274
|
override). A raw push is caught and reverted by the **post-push main audit**
|
|
275
275
|
(`scripts/gds/main-audit.js`) — nightly and as the `~/deploy.sh` deploy gate — which is the
|
|
276
276
|
authoritative layer (it reads the real git history + real ranks, independent of the builder).
|
|
277
|
+
- **The pre-push hook fails OPEN by contract, and the wrapper is what enforces that** (task 1002725).
|
|
278
|
+
The gate exits **13** — and only 13 — when it *deliberately* blocks; `.husky/pre-push` turns that
|
|
279
|
+
into git's exit 1. Any other non-zero exit is a **crash** and the wrapper warns loudly and allows
|
|
280
|
+
the push. This is deliberate: Layer 1 is cooperative, so a hook bug must never brick every push,
|
|
281
|
+
and the fail-closed net is the post-push audit above. The reserved code exists because node itself
|
|
282
|
+
exits 1 on a syntax error — `1` cannot distinguish a verdict from a fall-over. The gate therefore
|
|
283
|
+
must never call `process.exit()`: doing so after its rank-lookup `fetch` aborts node natively on
|
|
284
|
+
Windows (libuv `UV_HANDLE_CLOSING`), which runs no JS handler and exited 127, silently inverting
|
|
285
|
+
fail-open into "no Windows builder can push at all".
|
|
277
286
|
- CLAUDE.md §10 carries a portable **behavioral** framing of this rule (product builders build the
|
|
278
287
|
product, not the platform core they stand on); this doc holds the rank-name detail and the code is
|
|
279
288
|
the enforcement. Editing CLAUDE.md or the hook locally cannot remove the audit/deploy-gate.
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
# Projects Surfaces — Redesign Direction
|
|
2
|
+
|
|
3
|
+
> **Task 1002695** (goal 1000059 "Projects surface revamp", R06) · direction decided with the owner 2026-08-12.
|
|
4
|
+
> Feeds **task 1002696** (R07 — the implementation carrier) and the re-walk (task 1002697, with task 1002715 folded in).
|
|
5
|
+
> Findings feed: the QA/UAT checkpoint walk (task 1002702, [session log](../session-logs/<redacted>.md)) and the truthfulness cluster (task 1002720, findings F15–F23).
|
|
6
|
+
|
|
7
|
+
Scope: the three owner-facing project surfaces — the **create wizard** (`public-landing/create.html`), the **done panel** (`panel-done` in the same file), and the **manage view** (`public-landing/projects.html`, served at `/projects`). **All three live on the apex host — `cloudbongos.com` — and stay there.** Project creation and management are not builders-hall surfaces; the hall (builders subdomain) may link *out* to them, nothing more. The gate catalog stays with goal 1000031; the hall's federation my-projects page is a different surface and is out of scope.
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
## 0. Starting point — what exists today
|
|
12
|
+
|
|
13
|
+
- The flows **work end-to-end** (verified live on both venues by the checkpoint walk): create → address → GitHub sign-in → manage → teardown. What's broken is the *trust surface*, not the plumbing.
|
|
14
|
+
- Both pages are self-contained orphans: one inline `<style>` each, hand-rolled `:root`, no shared components, ~half the palette hardcoded past the branding contract's reach. The repo holds three unrelated design systems (sunrise landing, indigo hall kit, marble status page); these surfaces belong to the sunrise family but share no code with anything.
|
|
15
|
+
- Lifecycle truth lives in `provisioning_instances.status` — six runner-written states: `requested · provisioning · active · tearing_down · torn_down · error`. The UI maps them to chips (`queued / standing up / live / snagged / tearing down / torn down`).
|
|
16
|
+
- The three failure themes the walk proved (details in §§2–5): the platform asserts states it never verified, in both directions; the copy speaks operator; and the first-run paths (self-host, first admin, newborn instance) were never designed.
|
|
17
|
+
|
|
18
|
+
## 1. Decisions (owner, 2026-08-12)
|
|
19
|
+
|
|
20
|
+
1. **Truth model: heartbeat + honest chips.** The platform re-checks liveness on a background cadence and the UI shows verification recency. "Live" becomes a verified claim.
|
|
21
|
+
2. **Visual system: sunrise, tokenized — refined through design iterations.** Keep the sunrise landing brand; extract a shared token + component layer; the final look is nailed down through owner-review loops during implementation (§10), not invented in this doc.
|
|
22
|
+
3. **First-run: designed now, delivered split.** This direction defines the shape-aware surfaces; task 1002696 implements the surface parts, and runner/infra hardening stays in its parallel lane (tasks 1002717–1002722).
|
|
23
|
+
4. **Deliverable: this direction doc.** Owner sign-off on this doc is the task's done condition.
|
|
24
|
+
|
|
25
|
+
Also folded in, decided earlier the same day: **task 1002704 — auto-assign `<slug>.cloudbongos.com` at creation, opt-out.** The address step collapses from a form into a confirmation (§6).
|
|
26
|
+
|
|
27
|
+
## 2. Principles
|
|
28
|
+
|
|
29
|
+
1. **The UI only asserts what the platform verified — and says when.** Every liveness claim carries recency. Unverified is stated as unverified, never dressed as fact.
|
|
30
|
+
2. **Copy speaks owner, not operator.** No raw tool output on a card. No wall-clock promises. No process jargon. What happened, what it means, what (if anything) the owner can do.
|
|
31
|
+
3. **Deliberate end-states are calm.** A teardown the owner asked for is not an error and is never styled as one.
|
|
32
|
+
4. **Every flow owns its vocabulary.** Stage labels are never reused across flows — a card being torn down must not render a stage named "Live".
|
|
33
|
+
5. **Surfaces are honest about hosting shape.** A step renders only if somebody real will actually perform it on this instance's shape. Dead copy is a broken promise.
|
|
34
|
+
6. **One themeable token layer.** Everything an instance can't re-theme through the branding contract is a bug.
|
|
35
|
+
|
|
36
|
+
## 3. Truth model — the verification overlay
|
|
37
|
+
|
|
38
|
+
Verification is an **overlay on lifecycle, not a new lifecycle state**. The runner's six statuses stay untouched; verification adds recorded observations about whether the instance actually answers.
|
|
39
|
+
|
|
40
|
+
**Server (small piece, ships inside task 1002696):**
|
|
41
|
+
- `provisioning_instances` gains `last_verified_at` (timestamptz) and `last_verified_up` (boolean); both nullable.
|
|
42
|
+
- A heartbeat sweep in the provisioning module (in-process interval on the control plane, ~5-minute cadence with jitter) probes every `active` instance: `GET https://<domain>/healthz` when addressed, the recorded local port otherwise, short timeout. It writes the two columns and never flips `status`.
|
|
43
|
+
- `GET /provisioning/instances` and `/:id` expose both fields.
|
|
44
|
+
|
|
45
|
+
**Display contract (chips on both surfaces):**
|
|
46
|
+
|
|
47
|
+
| Condition | Chip |
|
|
48
|
+
|---|---|
|
|
49
|
+
| `active`, last probe up, fresh (≤ 2 cadences) | **live** · checked 2m ago (green) |
|
|
50
|
+
| `active`, last probe failed | **unreachable** · since 14:02 (amber/red) |
|
|
51
|
+
| `active`, never probed or stale probe | **live?** · last seen 3h ago (amber) |
|
|
52
|
+
|
|
53
|
+
- The manage view refreshes settled cards on a slow interval (~60s) and on tab focus, so the overlay stays current while someone is looking. (Today an `active` card never polls again and can never self-correct.)
|
|
54
|
+
- Provision-time gating (walk finding F15 — the runner declared `active` after a failed probe) is **task 1002720's** server work; this direction sets its display contract: the runner either gates `active` on a passing probe or writes `last_verified_up=false` that the UI must render.
|
|
55
|
+
- The inverse direction (F23 — a *successful* GitHub-App setup recorded as failed after a restart race, then a single-use manifest code retried forever) is also task 1002720's; the display contract here is that the done panel's sign-in step **waits and re-verifies** before declaring failure, and never claims failure for a setup it hasn't conclusively observed failing.
|
|
56
|
+
- Address attach (F16): "answering at `<domain>`" is claimed only after a probe **against the domain** succeeds. If DNS wiring was skipped (no hook), the card says exactly what's left: "point your domain's A record at `<ip>` — we'll confirm once it answers."
|
|
57
|
+
|
|
58
|
+
## 4. Vocabulary — states, chips, strips
|
|
59
|
+
|
|
60
|
+
**Chips** (one set, both surfaces): `queued · standing up · live (·recency) · live? · unreachable · snagged · tearing down · torn down`. `torn down` renders neutral (faint ink, no color), never red.
|
|
61
|
+
|
|
62
|
+
**Progress strips are per-flow** — distinct labels, chosen by the open intent on the card (F22), not by lifecycle status alone:
|
|
63
|
+
|
|
64
|
+
| Flow | Stages |
|
|
65
|
+
|---|---|
|
|
66
|
+
| Standup | Request filed → Standing it up → Live |
|
|
67
|
+
| Teardown | Teardown queued → Winding down → Torn down |
|
|
68
|
+
| Revive | Revive queued → Standing it up → Back live |
|
|
69
|
+
| Address | Address queued → Wiring the address → Answering at `<domain>` |
|
|
70
|
+
|
|
71
|
+
- A torn-down card with a pending revive intent shows the **revive strip** — never "you can start another project below" (F22's actively-wrong guidance).
|
|
72
|
+
- A strip's final stage lights only when the truth model confirms it (probe answered), not when the runner finishes its script.
|
|
73
|
+
|
|
74
|
+
## 5. Copy rules — owner voice
|
|
75
|
+
|
|
76
|
+
1. **Errors are a plain sentence + a disclosure.** Map the failed runner step to an owner-facing category — "We couldn't reach the project's repository," "The server didn't come up," "The address didn't wire up." The raw `error_note` (git stderr, ssh noise) moves into a `<details>` labeled "Technical details" — present for whoever helps, never the headline.
|
|
77
|
+
2. **No wall-clock promises.** Kill "usually under a minute" and friends. Describe what's happening and what happens next; never how long.
|
|
78
|
+
3. **Retry honesty (F19).** Intents retire after 3 attempts and nothing auto-retries. The snagged card says so — "Stopped after 3 tries" — and gains an owner-facing **Try again** action that re-enqueues (the enqueue API already accepts re-provisioning; wire it). "The platform keeps retrying" copy dies.
|
|
79
|
+
4. **Teardown truth (F17) — decided: the database is not dropped.** (Owner, 2026-08-12: no database drop unless we find otherwise — retention is policy, not an accident.) The confirm and the torn-down card say so plainly: the server goes away, the repository stays the owner's, the database is kept and recoverable by the operator. Copy always states exactly what the runner does.
|
|
80
|
+
5. **Repo visibility is stated, always (task 1002721).** Every path that creates or adopts a repo says whether it will be public or private, before the owner commits — GitHub's own UI must never be the first place visibility appears.
|
|
81
|
+
6. **Newborn welcome (idea 1000701).** A freshly-provisioned instance greets its founding owner with a welcome moment, and account nags (billing, art-key coverage) are suppressed until first-run completes.
|
|
82
|
+
|
|
83
|
+
## 6. Surface · Create wizard
|
|
84
|
+
|
|
85
|
+
Beats stay **Name · Home · Address · Create** — the flow shape survives; the address beat changes meaning and everything gets the copy + token pass.
|
|
86
|
+
|
|
87
|
+
- **Gate panel:** unchanged in flow (GitHub sign-in, AGPL trust link); copy pass only.
|
|
88
|
+
- **Name panel:** unchanged in flow; the live slug hint becomes the seed of the address confirmation.
|
|
89
|
+
- **Home panel:** the three-way progressive branch (picker / manual / fresh) survives; the manual path and the fresh path both state repo visibility explicitly (§5.5).
|
|
90
|
+
- **Address panel — collapses to a confirmation (task 1002704):** headline shows the assigned address — "`<slug>.cloudbongos.com` — yours from the moment it stands up." The slug stays editable inline (revalidating availability). A disclosure "Use a domain I own" holds the custom-domain input. Starting without an address remains possible only inside that disclosure, with the honest consequence stated: "sign-in on your instance can't be set up until it has an address."
|
|
91
|
+
- **Review panel:** the Domain row becomes the Address row and always shows the resolved address.
|
|
92
|
+
|
|
93
|
+
## 7. Surface · Done panel
|
|
94
|
+
|
|
95
|
+
- **Keep** the canonical-plan rendering (task 1002694's work: checklist from `GET /provisioning/onboard-plan`, manifest-driven sign-in leg, live tick states).
|
|
96
|
+
- **Shape-awareness:** the plan endpoint tags each step with the hosting shapes it applies to; the panel renders only steps real for this instance's shape. On shapes with no control-plane runner, `auto` steps either disappear or convert to explicit owner-instructions — no promise a runner will do what no runner exists to do.
|
|
97
|
+
- **The progress strip obeys the truth model:** "Live" lights on a verified answer, not on script completion; a lost verification race shows "confirming…" rather than a false ✖ (F23 display contract).
|
|
98
|
+
- **A door out:** the panel ends with a link to `/projects` — "watch it any time from Your projects."
|
|
99
|
+
|
|
100
|
+
## 8. Surface · /projects (manage view)
|
|
101
|
+
|
|
102
|
+
- **Card anatomy stays** (name + chip → meta → address → strip → message → actions) with the §3 chip overlay, §4 per-flow strips, and §5 message rules.
|
|
103
|
+
- **New actions:**
|
|
104
|
+
- **Bring it back** on `torn_down` cards — the enqueue API already revives a torn-down slug; the walk had to do it raw. Button + confirm, revive strip while it runs.
|
|
105
|
+
- **Try again** on `snagged` cards (§5.3).
|
|
106
|
+
- **Address row** shows the auto-assigned domain post-1002704; attach/change stays for custom domains, claiming "answering" only per §3.
|
|
107
|
+
- **Navigation doors (the page is currently orphaned):** the gate gets a signed-in "Your projects" door; the done panel links out (§7); the hall gets a cross-host link out to `cloudbongos.com/projects` (a door only — the surface itself never moves to the hall host). At least the gate and done-panel doors ship with task 1002696.
|
|
108
|
+
- **Refresh model:** slow interval + on-focus refetch for settled cards (§3); active-intent cards keep today's 5s poll.
|
|
109
|
+
|
|
110
|
+
## 9. First-run — designed now, delivered split
|
|
111
|
+
|
|
112
|
+
| First-run gap (walk source) | Design (this direction) | Delivery |
|
|
113
|
+
|---|---|---|
|
|
114
|
+
| Done panel promises a runner self-hosts don't have (task 1002717) | Shape-aware plan rendering (§7) | **task 1002696** |
|
|
115
|
+
| `FIRST_ADMIN` invisible — first user bounced to a nonexistent Owner (task 1002717) | A virgin instance's sign-in surface says plainly: "No one is admitted yet — the instance operator names the first admin," and points at the one-line fix (env var / branding field) | copy surface with task 1002696 where it lives in core UI; self-host docs + recipe with the **1002717 lane** |
|
|
116
|
+
| Newborn instance greets its founder with "your art coverage has ended" (idea 1000701) | §5.6 welcome + nag suppression until first-run completes | **task 1002696** if it stays a small `needs`-gating change; otherwise spun out as its own task |
|
|
117
|
+
| `/create` + `/projects` unreachable on single-origin self-hosts (task 1002717) | Out of this direction's hands — a mounting/architecture question | **1002717 lane** |
|
|
118
|
+
| Runner assumes the founder's box six ways; founder-identity hardcodes (tasks 1002718, 1002719) | Not a surface concern | **hardening lane** |
|
|
119
|
+
| Refuse-unknown dispatch + runner-drift gate (task 1002722) | Not a surface concern | **hardening lane** |
|
|
120
|
+
|
|
121
|
+
## 10. Visual system — sunrise, tokenized, iterated
|
|
122
|
+
|
|
123
|
+
**Base:** the sunrise landing brand stays — these surfaces are continuous with the gate, not the hall.
|
|
124
|
+
|
|
125
|
+
**Token + component layer** (new, shared by the three surfaces — e.g. `public-landing/assets/sunrise.css`):
|
|
126
|
+
- Tokens aligned to the branding contract's themeable set. The currently-hardcoded semantics (`--card`, `--line`, `--ok`, `--err`, chip fills, strip states, sky gradients) become tokens **derived from contract vars via `color-mix`** where that reads well; the contract's `COLOR_VARS` set is extended only where derivation fails aesthetically. End state: an instance pack re-themes the whole page, not half of it.
|
|
127
|
+
- Component set: page shell (sky header), card, chip, progress strip, step list, choice card, field row, disclosure, confirm dialog. Types and states defined once, used by all three surfaces.
|
|
128
|
+
|
|
129
|
+
**Iteration loops (owner-requested — the look is nailed by review, not by this doc):**
|
|
130
|
+
1. **Loop 1 — component sheet.** A static mock page showing the token palette and every component in every state (all chips, all strips, a card in each lifecycle). Owner reviews and directs before any surface is rebuilt.
|
|
131
|
+
2. **Loop 2 — applied surfaces.** The three rebuilt surfaces staged for an owner walk (stage preview or static) before task 1002696 ships.
|
|
132
|
+
3. **Final gate:** the re-walk (task 1002697, with the true-stranger pass task 1002715) is the acceptance test for the whole act.
|
|
133
|
+
|
|
134
|
+
## 11. Scope boundaries
|
|
135
|
+
|
|
136
|
+
**In (task 1002696 implements):** the three surfaces rebuilt on the shared token/component layer; heartbeat sweep + two columns + API exposure; chip overlay + per-flow strips; owner-voice copy pass incl. error categories + disclosures; address-step collapse (task 1002704); repo-visibility statements; Bring-it-back + Try-again actions; nav doors (gate, done panel); shape-aware done panel; newborn welcome/nag suppression (if small).
|
|
137
|
+
|
|
138
|
+
**Parallel (hardening lane, already seeded):** provision-time healthz gating, no-hook attach honesty, teardown-vs-dropdb reality, manifest race + spent-code fix (task 1002720); self-host reachability + FIRST_ADMIN recipe (task 1002717); runner assumptions (task 1002718); founder-identity hardcodes (task 1002719); dispatch/drift gates (task 1002722).
|
|
139
|
+
|
|
140
|
+
**Out:** the gate catalog (goal 1000031); the hall's federation my-projects surface; unreachable-instance Discord alerts (owner wants them — captured as idea 1000703, a follow-up on top of the heartbeat, deliberately not in task 1002696).
|
|
141
|
+
|
|
142
|
+
## 12. Implementation seams — what task 1002696 must not break
|
|
143
|
+
|
|
144
|
+
- The canonical onboard-plan contract (`GET /provisioning/onboard-plan`) and its consumers — the done panel renders *from* it; shape-awareness extends the plan, it doesn't fork it.
|
|
145
|
+
- The GitHub-App manifest flow (`startManifest` → public status/callback routes) — the one-click sign-in leg keeps working mid-redesign.
|
|
146
|
+
- The `sessionStorage` create-draft resume behavior.
|
|
147
|
+
- Branding injection: `landingPage()` string tokens (`{{productName}}` etc.) and `themeMarkup()`'s `:root` override — the new token layer must sit *under* it, not fight it.
|
|
148
|
+
- Apex-host mounting (`isApexHost`) — moving these pages off the control plane is 1002717-lane work, not this task.
|
|
149
|
+
- The provisioning API surface consumed by other callers; additions only, no breaking changes.
|
|
150
|
+
- `tests/onboard.mjs` and the walk's verified legs — the re-walk (task 1002697) must pass everything the checkpoint walk passed.
|
|
151
|
+
|
|
152
|
+
## 13. Sign-off dispositions (owner, 2026-08-12)
|
|
153
|
+
|
|
154
|
+
No open questions remain — both were decided at sign-off:
|
|
155
|
+
|
|
156
|
+
1. **Teardown does not drop the database** — retention is deliberate policy unless we find otherwise (§5.4). Any future change starts with the runner, and the copy follows it.
|
|
157
|
+
2. **Unreachable-instance Discord alerts: yes** — captured as idea 1000703, a follow-up on top of the heartbeat, outside task 1002696's scope.
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.77",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.19.
|
|
9
|
+
"version": "1.19.77",
|
|
10
10
|
"license": "AGPL-3.0-or-later",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"express": "^4.21.2",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.77",
|
|
4
4
|
"description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
|
|
5
5
|
"license": "AGPL-3.0-or-later",
|
|
6
6
|
"main": "src/platform-server.js",
|
package/scripts/gds/box.js
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
// park <builder> snapshot + destroy the droplet (stop compute billing)
|
|
15
15
|
// wake <builder> recreate the droplet from its snapshot
|
|
16
16
|
// deprovision <builder> destroy droplet AND snapshot (full reclaim)
|
|
17
|
-
// sweep-idle
|
|
17
|
+
// sweep-idle PARK every active box idle > BOX_IDLE_MINUTES (snapshot kept)
|
|
18
18
|
// sweep-dormant deprovision every box parked > BOX_DORMANT_DAYS (pings builder)
|
|
19
19
|
// status print the box roster + estimated cost
|
|
20
20
|
//
|
|
@@ -1007,33 +1007,47 @@ async function cmdDeprovision(pool, boxes, ref, { apply }) {
|
|
|
1007
1007
|
}
|
|
1008
1008
|
|
|
1009
1009
|
// ---- sweep-idle ------------------------------------------------------------
|
|
1010
|
-
// Idle boxes are
|
|
1011
|
-
//
|
|
1012
|
-
//
|
|
1013
|
-
//
|
|
1010
|
+
// Idle boxes are PARKED: snapshot taken, droplet destroyed (compute billing
|
|
1011
|
+
// stops), filesystem preserved so the builder wakes into their work. Snapshot
|
|
1012
|
+
// storage is ~$0.30/mo — the price of not silently discarding uncommitted work.
|
|
1013
|
+
//
|
|
1014
|
+
// This sweep deliberately never destroys a snapshot. Full reclaim (droplet AND
|
|
1015
|
+
// snapshot) belongs to sweep-dormant, which only fires after BOX_DORMANT_DAYS of
|
|
1016
|
+
// being parked and pings the builder first. That split is the whole safety
|
|
1017
|
+
// property: no automated path can take work off a builder without warning.
|
|
1018
|
+
//
|
|
1019
|
+
// History: this sweep used to call deprovisionOne directly, on the theory that
|
|
1020
|
+
// boxes are ephemeral and builders provision fresh each session. In practice it
|
|
1021
|
+
// meant a box idle past the threshold was annihilated — droplet and snapshot —
|
|
1022
|
+
// taking anything uncommitted with it, while the CLI help, the /builder-box
|
|
1023
|
+
// skill and the hall all still described park-and-wake. Task 1002726 restored
|
|
1024
|
+
// the documented behaviour rather than the documentation.
|
|
1014
1025
|
|
|
1015
1026
|
async function cmdSweepIdle(pool, boxes, { apply }) {
|
|
1016
1027
|
const rows = await boxes.listBoxes(pool, { state: 'active' });
|
|
1017
1028
|
const idle = boxes.selectIdleBoxes(rows, { idleMinutes: CONFIG.idleMinutes, nowMs: Date.now() });
|
|
1018
1029
|
console.log(`idle sweep (threshold=${CONFIG.idleMinutes}m${apply ? '' : ', dry-run'}): ${idle.length} of ${rows.length} active box(es) idle`);
|
|
1019
|
-
if (idle.length === 0) { console.log(' nothing idle — exit clean'); return {
|
|
1030
|
+
if (idle.length === 0) { console.log(' nothing idle — exit clean'); return { parked: 0 }; }
|
|
1020
1031
|
const doApi = apply ? makeDo(apply) : null;
|
|
1021
|
-
let
|
|
1032
|
+
let parked = 0, errors = 0;
|
|
1022
1033
|
for (const box of idle) {
|
|
1023
1034
|
const mins = Math.round((Date.now() - (boxes.effectiveActivityMs(box) || Date.now())) / 60000);
|
|
1024
|
-
if (!apply) { console.log(` would
|
|
1035
|
+
if (!apply) { console.log(` would park @${box.github_login}'s box (idle ${mins}m, droplet ${box.droplet_id})`); continue; }
|
|
1025
1036
|
try {
|
|
1026
|
-
await
|
|
1027
|
-
|
|
1028
|
-
console.log(` ✓
|
|
1037
|
+
await parkOne(pool, boxes, doApi, box, { actor: 'sweep:idle' });
|
|
1038
|
+
parked++;
|
|
1039
|
+
console.log(` ✓ parked @${box.github_login} (idle ${mins}m — snapshot kept, compute stopped)`);
|
|
1029
1040
|
} catch (e) {
|
|
1041
|
+
// A park that fails must NOT fall through to a destroy — leaving the box
|
|
1042
|
+
// at 'error' with its droplet intact is strictly safer than reclaiming it,
|
|
1043
|
+
// and reconcile-drift picks the row up from there.
|
|
1030
1044
|
errors++;
|
|
1031
1045
|
await markErrorUnlessDestroyed(pool, boxes, box.builder_id, e.message);
|
|
1032
1046
|
console.log(` ✖ @${box.github_login}: ${e.message}`);
|
|
1033
1047
|
}
|
|
1034
1048
|
}
|
|
1035
|
-
console.log(`done —
|
|
1036
|
-
return {
|
|
1049
|
+
console.log(`done — parked=${parked} errors=${errors} dry-run=${!apply}`);
|
|
1050
|
+
return { parked, errors };
|
|
1037
1051
|
}
|
|
1038
1052
|
|
|
1039
1053
|
// ---- sweep-dormant ---------------------------------------------------------
|
|
@@ -1427,4 +1441,4 @@ async function main() {
|
|
|
1427
1441
|
|
|
1428
1442
|
if (require.main === module) main();
|
|
1429
1443
|
|
|
1430
|
-
module.exports = { cmdProvision, deprovisionOne, deleteWithRetry, cmdReconcileDrift, runDnsHook, runDnsHookBlackhole, markErrorUnlessDestroyed, resolveBoxInfraDir, boxInfraCandidates, readCloudInit, BOX_INFRA_TEMPLATE };
|
|
1444
|
+
module.exports = { cmdProvision, deprovisionOne, parkOne, cmdSweepIdle, deleteWithRetry, cmdReconcileDrift, runDnsHook, runDnsHookBlackhole, markErrorUnlessDestroyed, resolveBoxInfraDir, boxInfraCandidates, readCloudInit, BOX_INFRA_TEMPLATE };
|
|
@@ -37,6 +37,24 @@ const { apiCall, productName } = require(path.join(__dirname, '..', 'cli-lib.js'
|
|
|
37
37
|
const ZERO = /^0+$/;
|
|
38
38
|
const MAIN_REF = 'refs/heads/main';
|
|
39
39
|
|
|
40
|
+
// The ONE exit code that means "the gate deliberately blocked this push". Any
|
|
41
|
+
// other non-zero exit is a CRASH, and .husky/pre-push translates it back to a
|
|
42
|
+
// warn-and-allow (task 1002725 / idea 1000704).
|
|
43
|
+
//
|
|
44
|
+
// WHY a reserved code and not the conventional 1: node itself exits 1 on a
|
|
45
|
+
// syntax error / unhandled rejection, so `1` cannot distinguish "we decided to
|
|
46
|
+
// block" from "the gate fell over". Reserving an unusual code means the wrapper
|
|
47
|
+
// only ever fails CLOSED on a decision this file actually made — every other
|
|
48
|
+
// shape (127 native abort, 134 SIGABRT, 1 syntax error) reads as a crash and
|
|
49
|
+
// fails OPEN, which is this layer's documented design (the post-push main audit
|
|
50
|
+
// is the fail-closed net).
|
|
51
|
+
const BLOCK_EXIT_CODE = 13;
|
|
52
|
+
|
|
53
|
+
// Sentinel returned by block() so each call site can `return block([...])` and
|
|
54
|
+
// stop the sweep. run() must never call process.exit() itself — see the note on
|
|
55
|
+
// the entrypoint at the bottom of this file.
|
|
56
|
+
const BLOCKED = Symbol('blocked');
|
|
57
|
+
|
|
40
58
|
function git(args) {
|
|
41
59
|
try {
|
|
42
60
|
return execSync(`git ${args}`, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
|
|
@@ -70,18 +88,25 @@ function changedFilesFor(localSha, remoteSha) {
|
|
|
70
88
|
const LIVE_RANK_TIMEOUT_MS = 4000;
|
|
71
89
|
|
|
72
90
|
async function liveRank() {
|
|
91
|
+
let timer = null;
|
|
73
92
|
try {
|
|
74
|
-
const timeout = new Promise((resolve) => setTimeout(() => resolve('__timeout__'), LIVE_RANK_TIMEOUT_MS));
|
|
93
|
+
const timeout = new Promise((resolve) => { timer = setTimeout(() => resolve('__timeout__'), LIVE_RANK_TIMEOUT_MS); });
|
|
75
94
|
const res = await Promise.race([
|
|
76
95
|
apiCall('GET', '/api/gds/me', null, { allowUnauthenticated: true }),
|
|
77
96
|
timeout,
|
|
78
97
|
]);
|
|
98
|
+
// The loser of the race must be cleared. This used to leak: process.exit()
|
|
99
|
+
// tore the loop down regardless, so nobody noticed. Now that the hook exits
|
|
100
|
+
// NATURALLY (task 1002725), an armed 4s timer would hold every
|
|
101
|
+
// protected-path push open for the full timeout after the API already
|
|
102
|
+
// answered.
|
|
103
|
+
if (timer) clearTimeout(timer);
|
|
79
104
|
if (res === '__timeout__') {
|
|
80
105
|
console.error(`\n\x1b[33m⚠ permission gate: GDS /me did not respond within ${LIVE_RANK_TIMEOUT_MS}ms — treating rank as unknown.\x1b[0m`);
|
|
81
106
|
return null;
|
|
82
107
|
}
|
|
83
108
|
if (res && res.ok) return res.data?.builder?.rank ?? null;
|
|
84
|
-
} catch (_) { /* offline / no session */ }
|
|
109
|
+
} catch (_) { /* offline / no session */ } finally { if (timer) clearTimeout(timer); }
|
|
85
110
|
return null;
|
|
86
111
|
}
|
|
87
112
|
|
|
@@ -89,7 +114,8 @@ function block(lines) {
|
|
|
89
114
|
console.error(`\n\x1b[31m✖ push blocked by the ${productName()} permission gate (SEC #863)\x1b[0m`);
|
|
90
115
|
for (const l of lines) console.error(' ' + l);
|
|
91
116
|
console.error('');
|
|
92
|
-
process.
|
|
117
|
+
process.exitCode = BLOCK_EXIT_CODE;
|
|
118
|
+
return BLOCKED;
|
|
93
119
|
}
|
|
94
120
|
|
|
95
121
|
async function run() {
|
|
@@ -103,13 +129,13 @@ async function run() {
|
|
|
103
129
|
const [localRef, localSha, remoteRef, remoteSha] = l.split(/\s+/);
|
|
104
130
|
return { localRef, localSha, remoteRef, remoteSha };
|
|
105
131
|
});
|
|
106
|
-
if (refs.length === 0)
|
|
132
|
+
if (refs.length === 0) return; // nothing to push
|
|
107
133
|
|
|
108
134
|
// Rule 1: no direct push to main.
|
|
109
135
|
if (!allowMain) {
|
|
110
136
|
for (const r of refs) {
|
|
111
137
|
if (r.remoteRef === MAIN_REF && !ZERO.test(r.localSha)) {
|
|
112
|
-
block([
|
|
138
|
+
return block([
|
|
113
139
|
'Direct pushes to `main` are not allowed.',
|
|
114
140
|
'The GDS lands graded work for you: run `/builder-ship` on your claimed task.',
|
|
115
141
|
'A direct push to main skips the grader, the confirm gate, parallel-safety, and',
|
|
@@ -128,7 +154,7 @@ async function run() {
|
|
|
128
154
|
}
|
|
129
155
|
const changedFiles = [...allChanged];
|
|
130
156
|
const protectedHits = ppc.matchProtected(changedFiles);
|
|
131
|
-
if (protectedHits.length === 0)
|
|
157
|
+
if (protectedHits.length === 0) return; // nothing sensitive — fast path
|
|
132
158
|
|
|
133
159
|
const rank = await liveRank();
|
|
134
160
|
if (rank === null) {
|
|
@@ -140,12 +166,12 @@ async function run() {
|
|
|
140
166
|
console.error(' This push touches permission-sensitive paths:');
|
|
141
167
|
for (const h of protectedHits.slice(0, 10)) console.error(` - ${h.file}`);
|
|
142
168
|
console.error(' Allowing the push; the server-side audit will re-check with your real rank.\n');
|
|
143
|
-
|
|
169
|
+
return;
|
|
144
170
|
}
|
|
145
171
|
|
|
146
172
|
const result = ppc.checkPermissionPaths({ changedFiles, builderRank: rank });
|
|
147
173
|
if (result.hardFail) {
|
|
148
|
-
block([
|
|
174
|
+
return block([
|
|
149
175
|
`Your rank '${rank}' is below Metic and may not modify permission-sensitive surfaces:`,
|
|
150
176
|
...protectedHits.slice(0, 12).map((h) => ` - ${h.file} (matches ${h.glob})`),
|
|
151
177
|
'',
|
|
@@ -154,20 +180,28 @@ async function run() {
|
|
|
154
180
|
'raise it with an Archon — do not edit it from a sandbox/newcomer rank (ADR 0016, ADR 0043).',
|
|
155
181
|
]);
|
|
156
182
|
}
|
|
157
|
-
process.exit(0);
|
|
158
183
|
}
|
|
159
184
|
|
|
160
185
|
// Only run when invoked directly (the husky wrapper does `node pre-push.js`).
|
|
161
186
|
// Requiring this file (e.g. a test importing matchProtected logic) must NOT
|
|
162
187
|
// read stdin or call process.exit.
|
|
188
|
+
//
|
|
189
|
+
// NOTE (task 1002725): nothing on this path may call process.exit(). Calling it
|
|
190
|
+
// after liveRank()'s fetch aborts the process natively on Windows —
|
|
191
|
+
// "Assertion failed: !(handle->flags & UV_HANDLE_CLOSING), file src\win\async.c"
|
|
192
|
+
// — because undici's handles are still closing when the loop is torn down. The
|
|
193
|
+
// abort exits 127 BEFORE the .catch() below can run, so the fail-open guarantee
|
|
194
|
+
// was silently inverted into "no Windows builder can push anything at all".
|
|
195
|
+
// Setting process.exitCode and letting the loop drain naturally is what keeps
|
|
196
|
+
// this hook's own error handling reachable.
|
|
163
197
|
if (require.main === module) {
|
|
164
198
|
run().catch((e) => {
|
|
165
199
|
// A hook crash must not silently allow a bad push of protected paths, but it
|
|
166
200
|
// also must not brick all pushes on an unrelated bug. Fail OPEN with a loud
|
|
167
201
|
// warning — the post-push audit remains the fail-closed net.
|
|
168
202
|
console.error(`\n⚠ permission pre-push hook errored (${e.message}); allowing push. The server-side audit still applies.\n`);
|
|
169
|
-
process.
|
|
203
|
+
process.exitCode = 0;
|
|
170
204
|
});
|
|
171
205
|
}
|
|
172
206
|
|
|
173
|
-
module.exports = { run };
|
|
207
|
+
module.exports = { run, BLOCK_EXIT_CODE };
|
package/src/module-api.js
CHANGED
|
@@ -577,8 +577,12 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
|
|
|
577
577
|
// landed since 1.19.73 with no explicit bump. run 31628379432. (task 1002620)
|
|
578
578
|
// 1.19.75 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
579
579
|
// landed since 1.19.74 with no explicit bump. run 31632769733. (task 1002620)
|
|
580
|
+
// 1.19.76 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
581
|
+
// landed since 1.19.75 with no explicit bump. run 31637719876. (task 1002620)
|
|
582
|
+
// 1.19.77 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
583
|
+
// landed since 1.19.76 with no explicit bump. run 31647860631. (task 1002620)
|
|
580
584
|
// ---------------------------------------------------------------------------
|
|
581
|
-
const CORE_VERSION = '1.19.
|
|
585
|
+
const CORE_VERSION = '1.19.77'; // CI auto-patch carrier (ADR 0161); see changelog above
|
|
582
586
|
|
|
583
587
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
584
588
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
package/tests/boxes.mjs
CHANGED
|
@@ -1251,5 +1251,86 @@ await ta('markErrorUnlessDestroyed: a genuine (pre-destroyed) failure still park
|
|
|
1251
1251
|
assert.match(writes[0].patch.error_note, /DO 500/);
|
|
1252
1252
|
});
|
|
1253
1253
|
|
|
1254
|
+
console.log('\ntask 1002726 — the idle sweep parks, it does not destroy:');
|
|
1255
|
+
|
|
1256
|
+
// The regression this pins: cmdSweepIdle used to call deprovisionOne, so an idle
|
|
1257
|
+
// box lost its droplet AND its snapshot — every sweep:idle row in the live
|
|
1258
|
+
// box_events history was a full teardown, and nothing could be woken. The sweep
|
|
1259
|
+
// must snapshot-and-park instead, leaving the filesystem recoverable.
|
|
1260
|
+
function sweepHarness({ idleRows, parkThrows = null }) {
|
|
1261
|
+
const events = [];
|
|
1262
|
+
const states = [];
|
|
1263
|
+
const fakeBoxes = {
|
|
1264
|
+
async listBoxes() { return idleRows; },
|
|
1265
|
+
selectIdleBoxes: boxes.selectIdleBoxes,
|
|
1266
|
+
effectiveActivityMs: boxes.effectiveActivityMs,
|
|
1267
|
+
async setBoxState(_db, _bid, state, patch) { states.push({ state, patch }); return {}; },
|
|
1268
|
+
async recordComputeCost() { return 0; },
|
|
1269
|
+
async recordEvent(_db, e) { events.push(e); },
|
|
1270
|
+
async getBoxByBuilderId() { return { state: 'active' }; },
|
|
1271
|
+
};
|
|
1272
|
+
return { fakeBoxes, events, states, parkThrows };
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1275
|
+
await ta('cmdSweepIdle: an idle box is PARKED — snapshot retained, no snapshot delete', async () => {
|
|
1276
|
+
const NOW_ROW = {
|
|
1277
|
+
id: 3, builder_id: 3, state: 'active', droplet_id: '591743808', droplet_name: 'box-lars589',
|
|
1278
|
+
github_login: 'lars589', last_activity_at: new Date(Date.now() - 5 * 3600_000).toISOString(),
|
|
1279
|
+
claude_active: false, snapshot_id: null,
|
|
1280
|
+
};
|
|
1281
|
+
const h = sweepHarness({ idleRows: [NOW_ROW] });
|
|
1282
|
+
const deleted = [];
|
|
1283
|
+
const doApi = {
|
|
1284
|
+
dryRun: false,
|
|
1285
|
+
async powerOffDroplet() { return { action: { id: 1 } }; },
|
|
1286
|
+
async waitForAction() {},
|
|
1287
|
+
async snapshotDroplet() { return { action: { id: 2 } }; },
|
|
1288
|
+
async getDroplet() { return { droplet: { snapshot_ids: ['999'] } }; },
|
|
1289
|
+
async deleteDroplet(id) { deleted.push(`droplet:${id}`); },
|
|
1290
|
+
async deleteSnapshot(id) { deleted.push(`snapshot:${id}`); },
|
|
1291
|
+
};
|
|
1292
|
+
// makeDo() is only reached under --apply; inject our fake by stubbing the DO
|
|
1293
|
+
// client the command builds. cmdSweepIdle takes (pool, boxes, {apply}), so we
|
|
1294
|
+
// exercise parkOne directly with the same contract the sweep now uses.
|
|
1295
|
+
await boxCli.parkOne({}, h.fakeBoxes, doApi, NOW_ROW, { actor: 'sweep:idle' });
|
|
1296
|
+
|
|
1297
|
+
const parked = h.states.find((s) => s.state === 'parked');
|
|
1298
|
+
assert.ok(parked, 'box reaches parked');
|
|
1299
|
+
assert.equal(parked.patch.snapshot_id, '999', 'snapshot id retained on the row');
|
|
1300
|
+
assert.equal(parked.patch.droplet_id, null, 'droplet released');
|
|
1301
|
+
assert.ok(deleted.includes('droplet:591743808'), 'droplet destroyed (compute billing stops)');
|
|
1302
|
+
assert.ok(!deleted.some((d) => d.startsWith('snapshot:')), 'the snapshot is NEVER deleted by an idle park');
|
|
1303
|
+
assert.equal(h.events.find((e) => e.event === 'park')?.actor, 'sweep:idle', 'logged as a park, not a deprovision');
|
|
1304
|
+
});
|
|
1305
|
+
|
|
1306
|
+
await ta('cmdSweepIdle: dry-run reports "would park" and touches nothing', async () => {
|
|
1307
|
+
const row = {
|
|
1308
|
+
id: 3, builder_id: 3, state: 'active', droplet_id: '591743808', github_login: 'lars589',
|
|
1309
|
+
last_activity_at: new Date(Date.now() - 5 * 3600_000).toISOString(), claude_active: false,
|
|
1310
|
+
};
|
|
1311
|
+
const h = sweepHarness({ idleRows: [row] });
|
|
1312
|
+
const lines = [];
|
|
1313
|
+
const origLog = console.log;
|
|
1314
|
+
console.log = (...a) => lines.push(a.join(' '));
|
|
1315
|
+
try {
|
|
1316
|
+
const res = await boxCli.cmdSweepIdle({}, h.fakeBoxes, { apply: false });
|
|
1317
|
+
assert.equal(res.parked, 0, 'dry-run parks nothing');
|
|
1318
|
+
} finally { console.log = origLog; }
|
|
1319
|
+
assert.ok(lines.some((l) => /would park/.test(l)), 'dry-run says park, not deprovision');
|
|
1320
|
+
assert.ok(!lines.some((l) => /would deprovision/.test(l)), 'no destroy language survives');
|
|
1321
|
+
assert.equal(h.states.length, 0, 'no state written in dry-run');
|
|
1322
|
+
});
|
|
1323
|
+
|
|
1324
|
+
await ta('cmdSweepIdle: a box running Claude is never parked mid-session', async () => {
|
|
1325
|
+
const busy = {
|
|
1326
|
+
id: 4, builder_id: 4, state: 'active', droplet_id: '1', github_login: 'busy',
|
|
1327
|
+
last_activity_at: new Date(Date.now() - 99 * 3600_000).toISOString(), claude_active: true,
|
|
1328
|
+
};
|
|
1329
|
+
const h = sweepHarness({ idleRows: [busy] });
|
|
1330
|
+
const res = await boxCli.cmdSweepIdle({}, h.fakeBoxes, { apply: true });
|
|
1331
|
+
assert.equal(res.parked, 0, 'claude_active holds the box open regardless of age');
|
|
1332
|
+
assert.equal(h.states.length, 0);
|
|
1333
|
+
});
|
|
1334
|
+
|
|
1254
1335
|
console.log(`\nboxes.mjs: ${passed} passed, ${failed} failed`);
|
|
1255
1336
|
process.exit(failed === 0 ? 0 : 1);
|
package/tests/pre_push_hook.mjs
CHANGED
|
@@ -3,12 +3,16 @@
|
|
|
3
3
|
// Pins three facts the 2026-08-06 choke-point map litigated:
|
|
4
4
|
// 1. G-17 refutation: pre-push.js's dependency path resolves INSIDE the repo
|
|
5
5
|
// (three `..` from scripts/gds/hooks/ is the root, not one above it).
|
|
6
|
-
// 2. The hook actually blocks a direct push to refs/heads/main (exit
|
|
7
|
-
//
|
|
8
|
-
//
|
|
6
|
+
// 2. The hook actually blocks a direct push to refs/heads/main (exit 13, the
|
|
7
|
+
// reserved block code the wrapper turns into git's exit 1), and allows a
|
|
8
|
+
// branch deletion (exit 0) — both offline-deterministic.
|
|
9
|
+
// 3. G-2 fix: the tracked .husky/pre-push wrapper exists, invokes the real
|
|
9
10
|
// gate, and is LF + executable in the git index (CRLF or mode 100644
|
|
10
11
|
// would silently kill the hook on Unix clones).
|
|
11
12
|
//
|
|
13
|
+
// Plus (task 1002725) the fail-open contract: a CRASHED gate must not brick the
|
|
14
|
+
// push — see the block comment above those tests.
|
|
15
|
+
//
|
|
12
16
|
// DB-free, no network: the main-push block fires before any API call.
|
|
13
17
|
// Run: node --test tests/pre_push_hook.mjs
|
|
14
18
|
|
|
@@ -17,6 +21,7 @@ import { test } from 'node:test';
|
|
|
17
21
|
import { createRequire } from 'node:module';
|
|
18
22
|
import { spawnSync } from 'node:child_process';
|
|
19
23
|
import fs from 'node:fs';
|
|
24
|
+
import os from 'node:os';
|
|
20
25
|
import path from 'node:path';
|
|
21
26
|
import { fileURLToPath } from 'node:url';
|
|
22
27
|
|
|
@@ -26,6 +31,7 @@ const HOOK = path.join(ROOT, 'scripts', 'gds', 'hooks', 'pre-push.js');
|
|
|
26
31
|
const WRAPPER = path.join(ROOT, '.husky', 'pre-push');
|
|
27
32
|
const SHA = 'a'.repeat(40);
|
|
28
33
|
const ZEROS = '0'.repeat(40);
|
|
34
|
+
const BLOCK_EXIT_CODE = 13; // pre-push.js's reserved "deliberately blocked" code
|
|
29
35
|
|
|
30
36
|
function runHook(stdinLine) {
|
|
31
37
|
return spawnSync(process.execPath, [HOOK, 'origin', 'https://example.invalid/repo.git'], {
|
|
@@ -36,6 +42,30 @@ function runHook(stdinLine) {
|
|
|
36
42
|
});
|
|
37
43
|
}
|
|
38
44
|
|
|
45
|
+
// Run the WRAPPER (not the gate) under `sh`, optionally with a fake `node`
|
|
46
|
+
// earlier on PATH so we can drive the gate's exit code without needing the gate
|
|
47
|
+
// to actually crash. This is how the fail-open contract is testable at all: a
|
|
48
|
+
// native abort cannot be provoked on demand, but the wrapper only ever sees an
|
|
49
|
+
// exit code, so substituting the code proves the translation.
|
|
50
|
+
function runWrapper(stdinLine, { fakeNodeExit = null, fakeNodeStderr = '' } = {}) {
|
|
51
|
+
let shimDir = null;
|
|
52
|
+
const env = { ...process.env, OTB_ALLOW_MAIN_PUSH: '' };
|
|
53
|
+
if (fakeNodeExit !== null) {
|
|
54
|
+
shimDir = fs.mkdtempSync(path.join(os.tmpdir(), 'prepush-shim-'));
|
|
55
|
+
const shim = path.join(shimDir, 'node');
|
|
56
|
+
fs.writeFileSync(shim, `#!/bin/sh\n[ -n "${fakeNodeStderr}" ] && printf '%s\\n' "${fakeNodeStderr}" >&2\nexit ${fakeNodeExit}\n`);
|
|
57
|
+
fs.chmodSync(shim, 0o755);
|
|
58
|
+
env.PATH = `${shimDir}${path.delimiter}${env.PATH}`;
|
|
59
|
+
}
|
|
60
|
+
try {
|
|
61
|
+
return spawnSync('sh', [WRAPPER, 'origin', 'https://example.invalid/repo.git'], {
|
|
62
|
+
cwd: ROOT, input: stdinLine, encoding: 'utf8', env,
|
|
63
|
+
});
|
|
64
|
+
} finally {
|
|
65
|
+
if (shimDir) fs.rmSync(shimDir, { recursive: true, force: true });
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
39
69
|
test('G-17 refuted: the line-30 dependency path resolves inside the repo and loads', () => {
|
|
40
70
|
const hooksDir = path.dirname(HOOK);
|
|
41
71
|
const resolved = path.join(hooksDir, '..', '..', '..', 'src', 'bongos', 'permission-path-check.js');
|
|
@@ -45,9 +75,9 @@ test('G-17 refuted: the line-30 dependency path resolves inside the repo and loa
|
|
|
45
75
|
assert.equal(typeof ppc.checkPermissionPaths, 'function');
|
|
46
76
|
});
|
|
47
77
|
|
|
48
|
-
test('hook blocks a direct push to refs/heads/main with
|
|
78
|
+
test('hook blocks a direct push to refs/heads/main with the reserved block code', () => {
|
|
49
79
|
const r = runHook(`refs/heads/main ${SHA} refs/heads/main ${SHA}\n`);
|
|
50
|
-
assert.equal(r.status,
|
|
80
|
+
assert.equal(r.status, BLOCK_EXIT_CODE, `expected exit ${BLOCK_EXIT_CODE}, got ${r.status}; stderr: ${r.stderr}`);
|
|
51
81
|
assert.match(r.stderr, /push blocked/i);
|
|
52
82
|
assert.match(r.stderr, /Direct pushes to `main`/);
|
|
53
83
|
});
|
|
@@ -62,7 +92,7 @@ test('requiring the hook as a module neither reads stdin nor exits', () => {
|
|
|
62
92
|
assert.equal(typeof mod.run, 'function');
|
|
63
93
|
});
|
|
64
94
|
|
|
65
|
-
test('G-2 fixed: tracked .husky/pre-push wrapper
|
|
95
|
+
test('G-2 fixed: tracked .husky/pre-push wrapper invokes the real gate', () => {
|
|
66
96
|
assert.ok(fs.existsSync(WRAPPER), '.husky/pre-push must be tracked (ADR 0043 Layer 1 delivery)');
|
|
67
97
|
const body = fs.readFileSync(WRAPPER, 'utf8');
|
|
68
98
|
assert.ok(body.startsWith('#!/bin/sh'), 'wrapper must start with a sh shebang');
|
|
@@ -70,6 +100,73 @@ test('G-2 fixed: tracked .husky/pre-push wrapper execs the real gate', () => {
|
|
|
70
100
|
assert.ok(!body.includes('\r'), 'wrapper must be LF-only (CRLF breaks the shebang on Unix)');
|
|
71
101
|
});
|
|
72
102
|
|
|
103
|
+
// ---------------------------------------------------------------------------
|
|
104
|
+
// task 1002725 / idea 1000704 — the fail-open guarantee is REAL, not aspirational.
|
|
105
|
+
//
|
|
106
|
+
// The gate is documented to fail OPEN on a crash (it is cooperative Layer 1; the
|
|
107
|
+
// post-push main audit is the fail-closed net). On Windows + Node 24 it instead
|
|
108
|
+
// died with a native libuv abort — "Assertion failed: !(handle->flags &
|
|
109
|
+
// UV_HANDLE_CLOSING), file src\win\async.c, line 76" — triggered by calling
|
|
110
|
+
// process.exit() after liveRank()'s fetch, while undici's handles were still
|
|
111
|
+
// closing. A native abort runs no JS, so the gate's own .catch() never fired; the
|
|
112
|
+
// process exited 127 and git refused EVERY push. A Windows builder could not land
|
|
113
|
+
// any work at all.
|
|
114
|
+
//
|
|
115
|
+
// Two invariants pin the fix:
|
|
116
|
+
// 1. the gate never calls process.exit() (the abort's trigger), and
|
|
117
|
+
// 2. the WRAPPER — the only layer that survives a native abort — translates a
|
|
118
|
+
// crash back into allow-with-a-warning, and a deliberate block into exit 1.
|
|
119
|
+
// ---------------------------------------------------------------------------
|
|
120
|
+
|
|
121
|
+
test('gate never calls process.exit() — that is what aborts natively on Windows', () => {
|
|
122
|
+
const src = fs.readFileSync(HOOK, 'utf8');
|
|
123
|
+
const withoutComments = src.replace(/\/\/[^\n]*/g, '').replace(/\/\*[\s\S]*?\*\//g, '');
|
|
124
|
+
assert.ok(
|
|
125
|
+
!/process\.exit\s*\(/.test(withoutComments),
|
|
126
|
+
'pre-push.js must set process.exitCode and let the loop drain; process.exit() after a fetch aborts node on Windows'
|
|
127
|
+
);
|
|
128
|
+
assert.match(src, /process\.exitCode/, 'the gate still has to report its verdict via process.exitCode');
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test('wrapper translates the reserved block code into a real git block (exit 1)', () => {
|
|
132
|
+
const r = runWrapper(`refs/heads/main ${SHA} refs/heads/main ${SHA}\n`);
|
|
133
|
+
assert.equal(r.status, 1, `a deliberate block must stop the push; got ${r.status}; stderr: ${r.stderr}`);
|
|
134
|
+
assert.match(r.stderr, /Direct pushes to `main`/);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test('wrapper FAILS OPEN when the gate crashes natively (the 1002725 regression)', () => {
|
|
138
|
+
const r = runWrapper(`refs/heads/main ${SHA} refs/heads/main ${SHA}\n`, {
|
|
139
|
+
fakeNodeExit: 127,
|
|
140
|
+
fakeNodeStderr: 'Assertion failed: !(handle->flags & UV_HANDLE_CLOSING)',
|
|
141
|
+
});
|
|
142
|
+
assert.equal(r.status, 0, `a crashed gate must NOT brick the push; got exit ${r.status}; stderr: ${r.stderr}`);
|
|
143
|
+
assert.match(r.stderr, /crashed \(exit 127\)/i, 'the crash must be reported loudly, not swallowed');
|
|
144
|
+
assert.match(r.stderr, /allowing push/i);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test('wrapper fails open on a plain node failure (exit 1) too, not just a native abort', () => {
|
|
148
|
+
// node exits 1 on a syntax error / unhandled rejection. That is a CRASH, not a
|
|
149
|
+
// verdict — which is precisely why the deliberate block needed its own code.
|
|
150
|
+
const r = runWrapper(`refs/heads/main ${SHA} refs/heads/main ${SHA}\n`, { fakeNodeExit: 1 });
|
|
151
|
+
assert.equal(r.status, 0, `exit 1 from node is a crash and must fail open; got ${r.status}`);
|
|
152
|
+
assert.match(r.stderr, /crashed \(exit 1\)/i);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test('wrapper allows a clean gate run (exit 0) untouched', () => {
|
|
156
|
+
const r = runWrapper(`refs/heads/some-branch ${ZEROS} refs/heads/some-branch ${SHA}\n`, { fakeNodeExit: 0 });
|
|
157
|
+
assert.equal(r.status, 0);
|
|
158
|
+
assert.doesNotMatch(r.stderr, /crashed/i, 'a clean run must not warn');
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test('wrapper does not exec the gate — it must survive to inspect the exit code', () => {
|
|
162
|
+
const body = fs.readFileSync(WRAPPER, 'utf8');
|
|
163
|
+
assert.ok(
|
|
164
|
+
!/^\s*exec\s+node/m.test(body),
|
|
165
|
+
'`exec node` replaces the shell, so a crash exit code propagates straight to git as a block'
|
|
166
|
+
);
|
|
167
|
+
assert.match(body, /\$\?/, 'the wrapper must capture the gate exit code');
|
|
168
|
+
});
|
|
169
|
+
|
|
73
170
|
test('wrapper is executable (mode 100755) in the git index', () => {
|
|
74
171
|
const r = spawnSync('git', ['ls-files', '-s', '--', '.husky/pre-push'], { cwd: ROOT, encoding: 'utf8' });
|
|
75
172
|
assert.equal(r.status, 0, `git ls-files failed: ${r.stderr}`);
|