@bongos/core 1.19.853 → 1.19.855

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 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.853",
6
- "core_contract": "1.19.853",
7
- "source_commit": "23d160b78996d913c0510928995bae59a1ae6832",
5
+ "core_version": "1.19.855",
6
+ "core_contract": "1.19.855",
7
+ "source_commit": "ea9bbb312aedb67961f643df3927bd9c3611d451",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-09-17T21:14:11.854Z",
9
+ "built_at": "2026-09-17T21:30:54.480Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
- "docs_redacted": 497,
12
+ "docs_redacted": 498,
13
13
  "agent_docs_stubbed": 26,
14
- "functional_verbatim": 2278,
14
+ "functional_verbatim": 2280,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 2801,
20
- "tree_sha256": "cfc90395d496db6fa9ae34f98458137adcc184faa73c806f2bfdba99027d74ff",
19
+ "file_count": 2804,
20
+ "tree_sha256": "f9cc99e0ebd1bf3f55d448b2204c056c832f6360a4941393b640f1db34e99093",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/ask-for-help/SKILL.md",
@@ -2019,10 +2019,15 @@
2019
2019
  "mode": "0000644",
2020
2020
  "sha256": "4571564a9bf5ccb08996bd1fae58b69945c6cccbc9ce3b58c634acc087cb7698"
2021
2021
  },
2022
+ {
2023
+ "path": "docs/adr/0306-instance-supervision-is-a-seam-with-systemd-as-the-default.md",
2024
+ "mode": "0000644",
2025
+ "sha256": "00d74b19e9574186fbe354e410f6acd9e84db60f11a550905e95ac9071b89aa6"
2026
+ },
2022
2027
  {
2023
2028
  "path": "docs/adr/README.md",
2024
2029
  "mode": "0000644",
2025
- "sha256": "00361bb3425bda49646f676baac8ef458bb3c5c32f128767c269e96985d41ccf"
2030
+ "sha256": "d65a10ca2341a236fb6397a84907ed3cc250f0ea72ea5ec4c3e9bfca6a9d6cb2"
2026
2031
  },
2027
2032
  {
2028
2033
  "path": "docs/api-reference.md",
@@ -2587,7 +2592,7 @@
2587
2592
  {
2588
2593
  "path": "docs/module-api-changelog.md",
2589
2594
  "mode": "0000644",
2590
- "sha256": "cd9e820a59b864c0b7c48431cbfe39edfeb421fcb807d24679a768c217373720"
2595
+ "sha256": "e272f0cf7f1826cdfedeb48806e9cec9caf6b926793d5308d5de9e137d47cb76"
2591
2596
  },
2592
2597
  {
2593
2598
  "path": "docs/modules-contract.md",
@@ -2697,7 +2702,7 @@
2697
2702
  {
2698
2703
  "path": "docs/recipes/builder-box-lifecycle.md",
2699
2704
  "mode": "0000644",
2700
- "sha256": "67d76943773d604d5534b2b8abb5b6aff1d64c7928e4d19a995d4cead2f406f6"
2705
+ "sha256": "dfffcee084534239d8a748b7468409644d9375e952143b0bd09b9c2c7341e844"
2701
2706
  },
2702
2707
  {
2703
2708
  "path": "docs/recipes/claude-design-loop.md",
@@ -2817,7 +2822,7 @@
2817
2822
  {
2818
2823
  "path": "docs/recipes/self-host.md",
2819
2824
  "mode": "0000644",
2820
- "sha256": "135f3942a01e50af87c4645f8920b25e0b779aac45cb5101b39e62cacfa59168"
2825
+ "sha256": "853761ea1330806204c35bcdda680af64fac1f096be1a1296d9d59ed9d54fabd"
2821
2826
  },
2822
2827
  {
2823
2828
  "path": "docs/recipes/server-bring-up.md",
@@ -7912,12 +7917,12 @@
7912
7917
  {
7913
7918
  "path": "package-lock.json",
7914
7919
  "mode": "0000644",
7915
- "sha256": "d6109cde0d5b5a6a8667252455ec1221dbcd4891b4937bfd240743990d0a354a"
7920
+ "sha256": "7e2895e4342846fb96f477f37493b4f813d0562e1882a7ce204f54c2787bc815"
7916
7921
  },
7917
7922
  {
7918
7923
  "path": "package.json",
7919
7924
  "mode": "0000644",
7920
- "sha256": "e4c6a0d643d7fbf5c9220a2449763f4cbd0b05594eec7fec5d1fffd7e691c54c"
7925
+ "sha256": "11f9ed62bce433c5f300faa1c4c052b810276df34ec1dcdca2d022a6e04e6370"
7921
7926
  },
7922
7927
  {
7923
7928
  "path": "public-docs/index.html",
@@ -8817,7 +8822,7 @@
8817
8822
  {
8818
8823
  "path": "scripts/gds/provision-config.js",
8819
8824
  "mode": "0000644",
8820
- "sha256": "b5ec038d150da93b4c54eeb864586a70e46610655ccb5bf10bd1adaa0432a305"
8825
+ "sha256": "d9d76ed5992ea84b347e0dbe37c78dda014df62208772ff00a2987d34ec27059"
8821
8826
  },
8822
8827
  {
8823
8828
  "path": "scripts/gds/provision-core-upgrade.js",
@@ -8834,6 +8839,11 @@
8834
8839
  "mode": "0000644",
8835
8840
  "sha256": "f1ca176ff8cdb8de65940af1e30316db600cb5df529abbc997697abf1cbc2c2d"
8836
8841
  },
8842
+ {
8843
+ "path": "scripts/gds/provision-process.js",
8844
+ "mode": "0000644",
8845
+ "sha256": "210d5135b157363499dc34bb8ac34eb7f661a61d8ba3d23e6a64953e5893559e"
8846
+ },
8837
8847
  {
8838
8848
  "path": "scripts/gds/provision-repo-auth.js",
8839
8849
  "mode": "0000644",
@@ -8847,7 +8857,7 @@
8847
8857
  {
8848
8858
  "path": "scripts/gds/provision-teardown.js",
8849
8859
  "mode": "0000644",
8850
- "sha256": "df4187852cf0f75a12571607af95782d16f1655aa4314152078cb9ee9b3d0f7c"
8860
+ "sha256": "9722d4099de9596df0e597e7e373c510dea312d8929c231705885e074cb0af12"
8851
8861
  },
8852
8862
  {
8853
8863
  "path": "scripts/gds/provision-units.js",
@@ -8857,7 +8867,7 @@
8857
8867
  {
8858
8868
  "path": "scripts/gds/provision.js",
8859
8869
  "mode": "0000644",
8860
- "sha256": "cbf44d415e3c5bb803e68af7b70ebfb08e7d0ab2388cc7f17a114512f29662e8"
8870
+ "sha256": "71d4edd0e5653207a535e6a0febca39a19ba281ffce7f248049b826dc5ab3b84"
8861
8871
  },
8862
8872
  {
8863
8873
  "path": "scripts/gds/publish-credential-check.js",
@@ -9857,7 +9867,7 @@
9857
9867
  {
9858
9868
  "path": "src/module-api.js",
9859
9869
  "mode": "0000644",
9860
- "sha256": "239294e8e4023ccc05619cd66992afebfceea6453821ec3ee7ceb8cabb540cf7"
9870
+ "sha256": "210e06e3e19060630a7d73a98aeceb948eb47b6690435d5ad8b624b0b1f05bc2"
9861
9871
  },
9862
9872
  {
9863
9873
  "path": "src/module-loader/catalog.js",
@@ -10424,6 +10434,11 @@
10424
10434
  "mode": "0000644",
10425
10435
  "sha256": "063c94d759fa8a79b8c402145801ae2a4d9863bef591fb5adf57697e15effc39"
10426
10436
  },
10437
+ {
10438
+ "path": "tests/box_sweep_docs.mjs",
10439
+ "mode": "0000644",
10440
+ "sha256": "e56b09e961b0c68ee27a6e5049750cd2828e94d9870ddc7837bf9de44fcaa0b7"
10441
+ },
10427
10442
  {
10428
10443
  "path": "tests/box_sync.mjs",
10429
10444
  "mode": "0000644",
@@ -12732,7 +12747,7 @@
12732
12747
  {
12733
12748
  "path": "tests/provision.mjs",
12734
12749
  "mode": "0000644",
12735
- "sha256": "edcb3a67c97291be694f06cf092233dbd78dbb56898547bc6ccb562e7c27da6b"
12750
+ "sha256": "396c26ec06858534e692d0ce1452d1944ea573743e6955df8e5ac22a655bfcdb"
12736
12751
  },
12737
12752
  {
12738
12753
  "path": "tests/provision_caddy_wiring.mjs",
@@ -0,0 +1,61 @@
1
+ # ADR 0306 — Instance supervision is a seam, with systemd as the default
2
+
3
+ **Status:** accepted · **Date:** 2026-09-17 · **Task:** [task 1003997](https://cloudbongos.com/builders#/task/1003997) (goal 1000106 — BONGOS-V2)
4
+
5
+ **Related:** [ADR 0111](0111-instance-hosting-provisioning-module.md) (the standup runbook this seam sits inside, and the enqueue-only trust boundary) · [ADR 0128](0128-provisioning-runner-privilege-and-db-auth-model.md) (`PROVISION_SUDO` — the escalation knob this ADR leaves alone) · [ADR 0130](0130-provision-remote-exec-over-ssh.md) (the precedent for adding a dimension to the injected exec seam) · [ADR 0281](0281-an-instance-identity-is-its-own-unix-account-and-pg-role.md) (`User=`/`Group=` is how instance isolation is enforced) · [ADR 0285](0285-a-shared-box-holds-about-twelve-projects-per-gb-and-memory-is-the-wall.md) (every live project is `hosting_shape='standalone'`) · [ADR 0303](0303-provisioning-verifies-the-base-caddyfile-and-never-edits-it.md) (detect-and-refuse rather than auto-edit shared host config)
6
+
7
+ ## Context
8
+
9
+ `provision-units.js` hardcoded `/etc/systemd/system` in three path functions, and the
10
+ runner reached for `systemctl` inline at fifteen call sites across `provision.js` and
11
+ `provision-teardown.js`. Supervision was not a choice anywhere — it was a string.
12
+
13
+ That is fine on the boxes we run today: every live project is `hosting_shape='standalone'`
14
+ on a systemd host (ADR 0285). It stops being fine the moment the plane is a container. The
15
+ self-host image runs `USER node`, installs no `sudo`, and boots no systemd, so the standup's
16
+ service step cannot run there at all.
17
+
18
+ **A directory knob does not fix this.** Pointing the unit path somewhere writable still
19
+ emits `systemctl daemon-reload` into a process namespace where systemd is not PID 1. The
20
+ thing that varies is not *where the unit file goes*; it is *what supervises the process*.
21
+
22
+ **systemd `--user` was considered and rejected.** It addresses an unprivileged VM — a user
23
+ session bus, `~/.config/systemd/user/` — but a plain container runs no systemd at all, user
24
+ bus included. It would have fixed a shape we do not have while leaving the one we do have
25
+ untouched.
26
+
27
+ ## Decision
28
+
29
+ **Instance supervision is a seam with two implementations, and systemd is the default.**
30
+
31
+ A process manager answers four questions about an instance's long-running service: install
32
+ and enable it, restart it, remove it, and install its nightly backup schedule. Those four
33
+ operations move behind `scripts/gds/provision-process.js`; the call sites ask the resolved
34
+ manager rather than composing `systemctl` strings.
35
+
36
+ **Selection is explicit, then probed, then defaulted:**
37
+
38
+ 1. `PROVISION_PROCESS_MANAGER` when set (an unknown value is a loud error, never a silent fallback);
39
+ 2. otherwise systemd when `/run/systemd/system` exists — the same check `sd_booted(3)` makes, and the one systemd itself documents for "was this host booted under systemd";
40
+ 3. otherwise supervisord.
41
+
42
+ **The live box is unchanged by construction.** It boots systemd, so the probe selects
43
+ systemd, and the systemd implementation emits byte-identical commands and unit paths to
44
+ what the inline strings emitted. The existing composition assertions in `tests/provision.mjs`
45
+ are the proof: they pin the exact strings and were not rewritten for this change.
46
+
47
+ **What the seam deliberately does NOT cover:**
48
+
49
+ - **Caddy reload.** `systemctl reload caddy` is shared-proxy config, governed by ADR 0303, and the container has no Caddy at all — that is [task 1004017](https://cloudbongos.com/builders#/task/1004017), not this seam.
50
+ - **The dedicated-droplet cloud-init path.** A DigitalOcean droplet always boots systemd; baking unit files into cloud-init stays systemd-specific on purpose.
51
+ - **Per-instance unix accounts.** `useradd` cannot run under `USER node`, so ADR 0281's isolation has no container equivalent yet — [task 1004018](https://cloudbongos.com/builders#/task/1004018).
52
+
53
+ ## Consequences
54
+
55
+ - **Isolation must be restated per manager.** systemd enforces ADR 0281 with `User=`/`Group=`; supervisord's equivalent is its `user=` directive, and the supervisord implementation sets it from the same `instanceUser(inst)`. A future third manager has to answer this question explicitly or it silently drops the boundary that stops one instance reading a sibling's `/proc/<pid>/environ`.
56
+
57
+ - **supervisord has no timers, so the nightly backup is skipped there — loudly.** supervisord supervises processes, not schedules. Rather than re-implement cron badly, the supervisord implementation installs no backup schedule and logs the exact `crontab` line an operator can add instead. A container self-host therefore has no automatic DB backup until that is addressed; the skip is visible in the standup log, not silent.
58
+
59
+ - **The seam is two implementations wide, not N.** It exists because we have two real planes, not as speculative pluggability. A third manager is a new implementation in one file, but nothing is abstracted further in anticipation of one.
60
+
61
+ - **This alone does not make container standup work.** It removes one of four blockers. The self-host container still has no ingress (task 1004017), no per-instance identity (task 1004018), and a backup directory owned by a hardcoded `APP_USER = 'lars'` ([task 1002719](https://cloudbongos.com/builders#/task/1002719)). Task 1003997's done-when was narrowed to this seam accordingly, so the ledger does not record a self-host story that is not there yet.
@@ -397,3 +397,4 @@ This keeps the decision history honest and traceable.
397
397
  | 0303 | [**Provisioning verifies the base Caddyfile, and never edits it** ([task 1002860](https://cloudbongos.com/builders#/task/1002860), goal 1000106, from idea 1000524). The runner assumed the box's `/etc/caddy/Caddyfile` carried the `on_demand_tls` tls-check ask and `import /etc/caddy/sites/*.caddy`, and reloaded with `allowFail` — a box missing either provisioned "successfully" and the instance was unreachable. It now reads Caddy's RESOLVED config (`caddy adapt`: unprivileged, no key access, indifferent to how the directives are written) before touching the box (the ask) and after writing the snippet (the domain is served ⇒ the import reaches it), refusing with the exact line to add, and the reload is loud. Rejected: editing the base file (`on_demand_tls` must sit inside the global block an append cannot place, and a bad write takes down the control plane's own apex); `caddy validate` (loads certificate keys, needs root).](0303-provisioning-verifies-the-base-caddyfile-and-never-edits-it.md) | provisioning / infra |
398
398
  | 0304 | [**Routine reads run without a prompt; anything a flag can turn into a write is exact-form** ([task 1003992](https://cloudbongos.com/builders#/task/1003992), goal 1000095). The shipped `.claude/settings.json` pre-allowed six commands, so every `gh pr view` or `git fetch` asked a non-technical builder to approve a command they cannot evaluate. The read-only subset is now pre-allowed — `git status`, `gh pr view/checks/list`, `gh run view/list` and the gds CLIs audited read-only under every flag as prefix rules; `git branch` and `git fetch` as exact forms only, because a flag turns either into a write or an exec — and the instance starter mirrors it rule for rule. `git diff/log/show` get NO rule: the harness already runs their read-only forms unprompted with a flag check, and a prefix rule switched that check off (`--output=<path>` writes anywhere, `diff --no-index` reads deny-listed files — both reproduced in review). A test pins the whole allow-list, that no rule covers a command git-guard blocks, and that no rule covers a write dressed as a read. Rejected: `Bash(git:*)`, prefix-plus-`ask` for diff/log/show (argument patterns are evaded by quoting), prefix rules for fetch/branch, `bypassPermissions` everywhere.](0304-routine-reads-run-without-a-prompt-and-anything-a-flag-can-turn-into-a-write-is-exact-form.md) | GDS / sessions / permissions |
399
399
  | 0305 | [**A recipe lives with what it documents** ([task 1002156](https://cloudbongos.com/builders#/task/1002156), goal 1000091, from idea 536 / idea 537). Part A of the 2026-07-10 architecture review, held until the owner confirmed direction. The binary core-vs-instance split in `isPublishable()` has collapsed to one side: all **36** files in `docs/recipes/` and all **4** in `docs/research/` publish, and the denylist rows that once held the instance-local half name files that no longer exist. Worse, it is producing wrong docs — four published recipes document the dev-box that goal 1000091 is deleting, and none of the nine removal tasks covers them ([task 1004013](https://cloudbongos.com/builders#/task/1004013)). The cause is not neglect: **nothing structurally ties a recipe to what it documents**. So the rule is ownership, not portability — a recipe lives in the same deletable unit as its subject. Three classes: core methodology stays in `docs/recipes/`; feature-coupled moves to `modules/<key>/recipes/` and dies with its module; instance-operational stays behind the publish gate. `scripts/` is unchanged (it is the CLI, not documentation with a portability question); `docs/research/` becomes instance-local (situated artifacts, and the likeliest brand-name leak). **The thesis is rejected in its literal form**: a shared recipe is not a NEW asset class parallel to modules but a directory *inside* one, reusing the manifest attribution, submit→review→accept, security accept-gate, default-off enablement and orphaning policy that ADR 0107/0135 already ship — `modules/ui-design/docs/design-contract.md` is the undesigned precedent. Rejected: a parallel registry (a second manifest, queue, attribution model and orphaning policy, to distribute Markdown; Supermarket/Galaxy distribute executables with dependency resolution, which is what a module already is); classify-and-keep-the-binary-split (treats the symptom, restores the local half only until the next module ships a recipe); per-recipe portability front-matter (no build step reads it, no deletion touches it); deleting `docs/research/` (live inputs to open goals). Decides direction only — no file moves; the sweep and a fitness check for a recipe outliving its module are follow-on.](0305-a-recipe-lives-with-what-it-documents.md) | modules / upstreaming |
400
+ | 0306 | [**Instance supervision is a seam, with systemd as the default** ([task 1003997](https://cloudbongos.com/builders#/task/1003997), goal 1000106, F13 from the task-1002718 QA walk). `provision-units.js` hardcoded `/etc/systemd/system` in three path functions and the runner reached for `systemctl` inline at fifteen call sites, so a standup was impossible on any plane without systemd — the self-host image runs `USER node`, installs no `sudo` and boots no systemd. **A directory knob does not fix this**: pointing the unit path somewhere writable still emits `daemon-reload` into a namespace where systemd is not PID 1. What varies is not where the unit file goes, it is what supervises the process. Chosen: four operations (install+enable, install backup schedule, restart, remove) move behind `scripts/gds/provision-process.js`, taking the `{ exec, writeFile, sudoP, log }` io the runbook already threads, so dry-run, remote-ssh and logging behaviour carry over untouched (ADR 0130). Selection is explicit (`PROVISION_PROCESS_MANAGER`, an unknown value throws) → probed (`/run/systemd/system`, the `sd_booted(3)` check) → supervisord. **The live box is unchanged by construction** — it boots systemd, and the systemd implementation emits byte-identical commands; the untouched composition assertions in `tests/provision.mjs` are the proof. Rejected: systemd `--user` units (fix an unprivileged VM we do not have, while a plain container has no user bus either). Consequences: isolation is restated per manager (systemd `User=` → supervisord `user=`, ADR 0281) or it silently drops; supervisord has no timers, so it installs no backup schedule and prints the `crontab` line instead of re-implementing cron; the seam is two implementations wide, not speculatively N. **This removes one of four container blockers** — ingress ([task 1004017](https://cloudbongos.com/builders#/task/1004017)), per-instance identity ([task 1004018](https://cloudbongos.com/builders#/task/1004018)) and the hardcoded backup-dir owner ([task 1002719](https://cloudbongos.com/builders#/task/1002719)) remain, so task 1003997's done-when was narrowed to the seam rather than claiming a self-host story that is not there.](0306-instance-supervision-is-a-seam-with-systemd-as-the-default.md) | infra / provisioning |
@@ -2180,5 +2180,9 @@ is load-bearing: the script throws rather than guess if it is missing, and
2180
2180
  landed since 1.19.851 with no explicit bump. run 35274827497. (task 1002620)
2181
2181
  1.19.853 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2182
2182
  landed since 1.19.852 with no explicit bump. run 35275456041. (task 1002620)
2183
+ 1.19.854 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2184
+ landed since 1.19.853 with no explicit bump. run 35276402271. (task 1002620)
2185
+ 1.19.855 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2186
+ landed since 1.19.854 with no explicit bump. run 35277047800. (task 1002620)
2183
2187
  ---------------------------------------------------------------------------
2184
2188
  ```
@@ -7,26 +7,30 @@
7
7
  Each builder gets **one** box (ADR 0031 §2). Its lifecycle is a small state machine tracked in the `builder_boxes` table:
8
8
 
9
9
  ```
10
- idle > BOX_IDLE_MINUTES (auto, every 15m) task 804:
11
- none ─provision─▶ active ──────────────────────────────▶ destroyed destroys
12
- ▲ │ ▲ droplet
13
- wake(manual) │ park(manual) │ + snapshot
14
- │ ▼ │
15
- parked ── parked > BOX_DORMANT_DAYS ──────┘
16
- (auto, daily)
10
+ none --provision--> active
11
+ ^ |
12
+ wake (manual, or | | idle > BOX_IDLE_MINUTES (auto, hourly)
13
+ on next connect) | | park (manual)
14
+ | v
15
+ parked --- parked > BOX_DORMANT_DAYS ---> destroyed
16
+ (auto, daily, WARNS FIRST) snapshot deleted
17
+
18
+ park keeps the SNAPSHOT (only the droplet is destroyed, to stop billing),
19
+ so it is reversible with `wake`. Dormant reclaim is the ONLY automated
20
+ path that destroys a snapshot, and it warns the builder first.
17
21
  ```
18
22
 
19
23
  - **provision** — create + boot a droplet running the [#596](https://example.com/builders#/task/596) devcontainer. **Rank-gated.**
20
- - **idle deprovision** (auto, every 15 min) — when an `active` box is idle > `BOX_IDLE_MINUTES`, **destroy the droplet AND its snapshot** ([#804](https://example.com/builders#/task/804)). Compute billing stops; nothing is kept. **Not reversible** — the next connect re-provisions a fresh box, and any uncommitted work on the old box is lost (spike [#837](https://example.com/builders#/task/837)).
21
- - **park** (manual Archon CLI only) — snapshot the droplet then **destroy** it. A powered-off DO droplet still bills; only a destroyed one stops. The snapshot (~$0.30/mo) is the cheap residual you can `wake` from. **No longer automatic** — the idle sweep deprovisions instead of parking ([#804](https://example.com/builders#/task/804)).
24
+ - **idle park** (auto, hourly) — when an `active` box is idle > `BOX_IDLE_MINUTES`, snapshot it and **destroy the droplet only**, keeping the snapshot ([task 1002726](https://example.com/builders#/task/1002726)). Compute billing stops; the box is **reversible** via `wake`. An earlier revision ([task 804](https://example.com/builders#/task/804)) destroyed the snapshot too; that was reverted because it took uncommitted work with it (spike [task 837](https://example.com/builders#/task/837)).
25
+ - **park** (the idle sweep, or an Archon by hand) — snapshot the droplet then **destroy the droplet**. A powered-off DO droplet still bills; only a destroyed one stops. The snapshot (~$0.30/mo) is the cheap residual you can `wake` from.
22
26
  - **wake** (manual, on demand) — recreate the droplet from its snapshot. New IP; the `hostname` is repointed via `BOX_DNS_HOOK`.
23
- - **dormant reclaim** (auto, daily) — destroy the snapshot of a box `parked` > `BOX_DORMANT_DAYS`. Since idle no longer parks, this only catches boxes an Archon **manually** parked.
27
+ - **dormant reclaim** (auto, daily) — destroy the snapshot of a box `parked` > `BOX_DORMANT_DAYS`. This is the **only automated path that destroys a snapshot**, so it **warns the builder first** and defers the reclaim if that warning was never delivered ([task 1002727](https://example.com/builders#/task/1002727)). Its input is whatever the idle sweep parked, plus any manual Archon park.
24
28
 
25
29
  ### How "idle" is detected
26
30
 
27
- The idle sweep **deprovisions** a box when its `last_activity_at` (else `active_since`) is older than `BOX_IDLE_MINUTES` **and** `claude_active` is not true. Both signals are set by **`infra/box-heartbeat.sh`**, which runs **inside the box** every ~5 min and POSTs `/api/gds/box/heartbeat` *only while the box is in use* — an interactive login session, an established SSH connection, an **open browser terminal** (ttyd websocket, [#1161](https://example.com/builders#/task/1161)), or non-trivial CPU load. Silence = the sweep is free to destroy it.
31
+ The idle sweep **parks** a box when its `last_activity_at` (else `active_since`) is older than `BOX_IDLE_MINUTES` **and** `claude_active` is not true. Both signals are set by **`infra/box-heartbeat.sh`**, which runs **inside the box** every ~5 min and POSTs `/api/gds/box/heartbeat` *only while the box is in use* — an interactive login session, an established SSH connection, an **open browser terminal** (ttyd websocket, [#1161](https://example.com/builders#/task/1161)), or non-trivial CPU load. Silence = the sweep is free to park it.
28
32
 
29
- > ⚠️ **The idle sweep's correctness depends on the box-side heartbeat reaching the server.** If it isn't pinging — no Bongos token on the box yet, a pre-[#1161](https://example.com/builders#/task/1161) script that can't see a browser terminal, or the cron not installed — `last_activity_at` never advances and the sweep falls back to `active_since`, i.e. it measures **uptime**, not activity, and will **destroy** a box someone is actively using. Because the idle path is now a full deprovision (no park to wake from), that wrongly-reaped box also loses uncommitted work (spike [#837](https://example.com/builders#/task/837)).
33
+ > ⚠️ **The idle sweep's correctness depends on the box-side heartbeat reaching the server.** If it isn't pinging — no Bongos token on the box yet, a pre-[#1161](https://example.com/builders#/task/1161) script that can't see a browser terminal, or the cron not installed — `last_activity_at` never advances and the sweep falls back to `active_since`, i.e. it measures **uptime**, not activity, and will **park** a box someone is actively using. Because the idle path keeps the snapshot ([task 1002726](https://example.com/builders#/task/1002726)), that box can be restored with `wake` and no work is lost — it is disruptive, not destructive. The destructive case is the dormant sweep, which warns first ([task 1002727](https://example.com/builders#/task/1002727)).
30
34
  >
31
35
  > **Live mitigation (2026-06-16):** `BOX_IDLE_MINUTES` is raised to **120** in `/etc/example/box.env` as a soak margin until [#1161](https://example.com/builders#/task/1161)'s browser-terminal heartbeat is verified on a newcomer box; lower it back toward the 10-min default once that's proven.
32
36
 
@@ -34,12 +38,14 @@ Two sweeps automate the middle (both mirror `stale-claim-release` — dry-run fi
34
38
 
35
39
  | Sweep | Cadence | What it does | Threshold |
36
40
  |---|---|---|---|
37
- | `box-idle-suspend` | every 15 min | **deprovision** `active` boxes with no activity — destroy droplet **and** snapshot ([#804](https://example.com/builders#/task/804)); not a park | `BOX_IDLE_MINUTES` (default 10; **currently 120 live**) |
38
- | `box-dormant-reclaim` | daily | destroy the snapshot of long-`parked` boxes (pings builder). Since idle no longer parks, only catches **manual** Archon parks | `BOX_DORMANT_DAYS` (14) |
41
+ | `box-idle-suspend` | hourly | **park** `active` boxes with no activity — snapshot kept, droplet destroyed ([task 1002726](https://example.com/builders#/task/1002726)); reversible via `wake` | `BOX_IDLE_MINUTES` (default 10; **currently 120 live**) |
42
+ | `box-dormant-reclaim` | daily | destroy the snapshot of long-`parked` boxes — the only automated snapshot delete, and it warns first ([task 1002727](https://example.com/builders#/task/1002727)) | `BOX_DORMANT_DAYS` (14) |
43
+
44
+ > ⚠️ **Both sweeps are currently `enabled: false`** in `config/scheduled-routines.json` — retired with the dev-box module (goal 1000091, [task 1003886](https://example.com/builders#/task/1003886)). Neither fires; the CLI below still works by hand.
39
45
 
40
46
  ## Why a self-built sweep (not Coder)
41
47
 
42
- ADR 0031 §9.3, resolved 2026-06-04: Coder needs an always-on control-plane droplet (~$12–24/mo fixed) + ops; a self-built sweep has ≈ $0 fixed cost and rides the existing sweep/timer pattern. See the ADR for the full rationale. (The original sweep snapshotted-and-parked idle boxes; [#804](https://example.com/builders#/task/804) simplified the idle path to a direct deprovision — the snapshot/wake machinery now serves only the manual `park`/`wake` commands.)
48
+ ADR 0031 §9.3, resolved 2026-06-04: Coder needs an always-on control-plane droplet (~$12–24/mo fixed) + ops; a self-built sweep has ≈ $0 fixed cost and rides the existing sweep/timer pattern. See the ADR for the full rationale. (The original sweep snapshotted-and-parked idle boxes; [task 804](https://example.com/builders#/task/804) briefly simplified the idle path to a direct deprovision, and [task 1002726](https://example.com/builders#/task/1002726) reverted it — so the snapshot/wake machinery serves the automatic idle path again, as well as the manual `park`/`wake` commands.)
43
49
 
44
50
  ## The CLI — `node scripts/gds/box.js`
45
51
 
@@ -95,7 +101,7 @@ Put these in `/etc/example/box.env` (chmod 600 — **secrets never go in the rep
95
101
  | `BOX_REGION` | `nyc3` | droplet region |
96
102
  | `BOX_SIZE` | `s-2vcpu-4gb` | droplet size (4 GB floor — the devcontainer sets a 4 GB Node heap) |
97
103
  | `BOX_BASE_IMAGE` | `ubuntu-24-04-x64` | first-provision base image (cloud-init installs Docker + the devcontainer) |
98
- | `BOX_IDLE_MINUTES` | `10` | idle-**deprovision** threshold (minutes). **Currently overridden to `120` live** as a soak margin — see "How idle is detected". |
104
+ | `BOX_IDLE_MINUTES` | `10` | idle-**park** threshold (minutes). **Currently overridden to `120` live** as a soak margin — see "How idle is detected". |
99
105
  | `BOX_DORMANT_DAYS` | `14` | dormant-reclaim threshold |
100
106
  | `BOX_SSH_KEY_IDS` | — | comma-list of DO SSH key ids/fingerprints to inject (so you can SSH in) |
101
107
  | `BOX_REPO_URL` | — | git URL the cloud-init clones into `/workspace`. The scoped **credential** is [#600](https://example.com/builders#/task/600); no identity is hardcoded. |
@@ -39,7 +39,7 @@ Author `config/branding.json` from the neutral starter — see [the branding con
39
39
 
40
40
  **Or scaffold it with `bongos init`** ([task #1204](https://example.com/builders#/task/1204)): a short interview (or `bongos init --from spec.json` for a hands-free / CI run) that writes both config files, **seeds your first version + its done-when goal**, and **files the human-only kickoff steps** (register a GitHub OAuth app, first owner sign-in, repo binding, admission) as real tracked tasks — so a fresh instance has a claimable queue on day one instead of an empty hall. It never invents your branding (it points you at the fill prompt for that). Preview the whole plan with `bongos init --dry-run`.
41
41
 
42
- **Production hosting, optional (the `provisioning` module — [ADR 0111](../adr/0111-instance-hosting-provisioning-module.md) §3).** If you enable the `provisioning` module and answer the interview's hosting questions (shape — co-tenant `$0` or a dedicated droplet — bring-your-own domain, credential ref, tier), init turns those answers into a real **provisioning request** (a queued `provision` intent the control-plane runner drains) instead of only filing a manual "production topology" task. **A queued request does nothing until a runner drains it:** on the control plane that is a systemd timer running `node scripts/gds/provision.js run-intents --apply`. This Docker image now carries the tools the scaffold needs — git, and a core it can package out of its own `.git`-less checkout (task 1003995) — but the standup leg still assumes root + systemd, which a container has not got (task 1003997), so on a compose self-host a new project waits indefinitely. The two irreducibly-human legs — the GitHub OAuth app and the first owner sign-in — stay guided/manual. If the module isn't enabled on the target instance, init falls back to the manual production-topology task, so nothing is silently skipped.
42
+ **Production hosting, optional (the `provisioning` module — [ADR 0111](../adr/0111-instance-hosting-provisioning-module.md) §3).** If you enable the `provisioning` module and answer the interview's hosting questions (shape — co-tenant `$0` or a dedicated droplet — bring-your-own domain, credential ref, tier), init turns those answers into a real **provisioning request** (a queued `provision` intent the control-plane runner drains) instead of only filing a manual "production topology" task. **A queued request does nothing until a runner drains it:** on the control plane that is a systemd timer running `node scripts/gds/provision.js run-intents --apply`. This Docker image now carries the tools the scaffold needs — git, and a core it can package out of its own `.git`-less checkout (task 1003995) — and supervision is no longer systemd-only: the runner picks a process manager, falling back to supervisord where systemd is not running (task 1003997, [ADR 0306](../adr/0306-instance-supervision-is-a-seam-with-systemd-as-the-default.md)). Three standup legs still assume a systemd box, so on a compose self-host a new project waits indefinitely: the container has no reverse proxy for a child instance's front door (task 1004017), cannot create the per-instance unix account that isolates one instance from another (task 1004018), and installs its backup dir as a hardcoded founder user (task 1002719). Note supervisord has no timer concept either, so a container-hosted instance gets no automatic DB backup — the standup log prints the `crontab` line to add instead. The two irreducibly-human legs — the GitHub OAuth app and the first owner sign-in — stay guided/manual. If the module isn't enabled on the target instance, init falls back to the manual production-topology task, so nothing is silently skipped.
43
43
 
44
44
  ## Configuration surface
45
45
 
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.853",
3
+ "version": "1.19.855",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.853",
9
+ "version": "1.19.855",
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.853",
3
+ "version": "1.19.855",
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",
@@ -63,6 +63,10 @@ const CONFIG = {
63
63
  // `<db>-<ts>.sql.gz`, so each instance's files + retention prune stay scoped to
64
64
  // its own DB. Owned by the app user, mode 0750. (task 2213, productizes 2210.)
65
65
  backupDir: process.env.PROVISION_BACKUP_DIR || '/var/backups/gds',
66
+ // Where supervisord program configs land when supervision is supervisord rather than
67
+ // systemd (ADR 0306). Unused on a systemd box. No sudo is applied to writes here: the
68
+ // planes that select supervisord run unprivileged and already own this dir.
69
+ supervisorConfDir: process.env.PROVISION_SUPERVISOR_CONF_DIR || '/etc/supervisor/conf.d',
66
70
  // Dedicated-droplet defaults (only used for hosting_shape='dedicated').
67
71
  region: process.env.PROVISION_REGION || 'nyc3',
68
72
  baseImage: process.env.PROVISION_BASE_IMAGE || 'ubuntu-24-04-x64',
@@ -0,0 +1,167 @@
1
+ // scripts/gds/provision-process.js — WHAT SUPERVISES an instance's long-running
2
+ // service, as a seam rather than a hardcoded string (task 1003997, ADR 0306).
3
+ //
4
+ // The standup used to reach for `systemctl` inline at fifteen call sites, which is fine
5
+ // on a systemd box and impossible anywhere else: the self-host image runs USER node with
6
+ // no sudo and boots no systemd at all. Pointing the unit path somewhere writable does not
7
+ // help — what varies is not where the unit file goes, it is what runs the process.
8
+ //
9
+ // A manager answers four questions: install+enable the service, install its nightly
10
+ // backup schedule, restart it, remove it. Each takes the instance row and an `io` of the
11
+ // SAME shape the runbook already threads around — { exec, writeFile, sudoP, log } — so
12
+ // the dry-run, remote-ssh and logging behaviour of makeExec/makeWriteFile carries over
13
+ // untouched (ADR 0130). Nothing here executes anything itself.
14
+ //
15
+ // systemd is the default and its commands are BYTE-IDENTICAL to the strings it replaced;
16
+ // tests/provision.mjs pins them. The live box boots systemd, so it keeps systemd.
17
+
18
+ 'use strict';
19
+
20
+ const fs = require('node:fs');
21
+ const path = require('node:path');
22
+ const { CONFIG, REPO_ROOT } = require('./provision-config.js');
23
+ const { APP_USER, CORE_PKG_DIR, instanceUser, standaloneRoot } = require('./provision-repo.js');
24
+ const {
25
+ backupScriptPath, backupService, backupServicePath, backupTimer, backupTimerPath,
26
+ backupUnitName, serviceUnit, serviceUnitPath,
27
+ } = require('./provision-units.js');
28
+
29
+ // ── systemd — the default, and what every live box runs ──────────────────────
30
+ const systemd = {
31
+ key: 'systemd',
32
+
33
+ installService(inst, { exec, writeFile, sudoP }) {
34
+ writeFile(serviceUnitPath(inst), serviceUnit(inst), { mode: 0o644, sudo: true });
35
+ exec(`${sudoP}systemctl daemon-reload`);
36
+ exec(`${sudoP}systemctl enable --now ${inst.slug}`);
37
+ },
38
+
39
+ // The backup DIR belongs to this step, not to the runbook: it exists only to serve the
40
+ // schedule, so a manager that installs no schedule must not create it either.
41
+ installBackup(inst, { exec, writeFile, sudoP }) {
42
+ exec(`${sudoP}install -d -o ${APP_USER} -g ${APP_USER} -m 0750 ${CONFIG.backupDir}`);
43
+ writeFile(backupServicePath(inst), backupService(inst), { mode: 0o644, sudo: true });
44
+ writeFile(backupTimerPath(inst), backupTimer(inst), { mode: 0o644, sudo: true });
45
+ exec(`${sudoP}systemctl daemon-reload`);
46
+ exec(`${sudoP}systemctl enable --now ${backupUnitName(inst)}.timer`);
47
+ return { installed: true };
48
+ },
49
+
50
+ restartService(inst, { exec, sudoP }) {
51
+ exec(`${sudoP}systemctl restart ${inst.slug}`);
52
+ },
53
+
54
+ // Teardown. Every step allowFail: a half-provisioned instance must still tear down.
55
+ // reset-failed is not optional (task 1003940): a removed unit's FAILED state outlives
56
+ // its file, so without it `systemctl --failed` lists torn-down ghosts for weeks.
57
+ removeService(inst, { exec, sudoP }) {
58
+ exec(`${sudoP}systemctl disable --now ${inst.slug}`, { allowFail: true });
59
+ exec(`${sudoP}rm -f ${serviceUnitPath(inst)}`, { allowFail: true });
60
+ exec(`${sudoP}systemctl disable --now ${backupUnitName(inst)}.timer`, { allowFail: true });
61
+ exec(`${sudoP}rm -f ${backupServicePath(inst)} ${backupTimerPath(inst)}`, { allowFail: true });
62
+ exec(`${sudoP}systemctl daemon-reload`, { allowFail: true });
63
+ exec(`${sudoP}systemctl reset-failed ${inst.slug}.service ${backupUnitName(inst)}.service`, { allowFail: true });
64
+ },
65
+ };
66
+
67
+ // ── supervisord — the container implementation ───────────────────────────────
68
+ // No sudo anywhere: the self-host image runs as an unprivileged user that already owns
69
+ // its conf dir, so escalation would fail rather than help.
70
+ function supervisorConfPath(inst) {
71
+ return path.posix.join(CONFIG.supervisorConfDir, `${inst.slug}.conf`);
72
+ }
73
+
74
+ // The ExecStart equivalent, resolved the same way serviceUnit resolves it so the two
75
+ // cannot drift: a standalone runs out of its own pinned core, anything else out of this
76
+ // checkout.
77
+ function programCommand(inst) {
78
+ return inst.hosting_shape === 'standalone'
79
+ ? `/usr/bin/node ${CORE_PKG_DIR}/src/platform-server.js`
80
+ : '/usr/bin/node src/platform-server.js';
81
+ }
82
+
83
+ function supervisorProgram(inst, { etcBase = CONFIG.etcBase } = {}) {
84
+ const workDir = inst.hosting_shape === 'standalone' ? standaloneRoot(inst) : REPO_ROOT;
85
+ return `[program:${inst.slug}]
86
+ ; Cloud Bongos instance — ${inst.slug} (provisioned by provision.js, ADR 0306)
87
+ command=${programCommand(inst)}
88
+ directory=${workDir}
89
+ ; The isolation systemd spells User=/Group= (ADR 0281). A shared uid would let every
90
+ ; instance on this plane read its siblings' /proc/<pid>/environ — every OAuth cred in
91
+ ; the env file below. Dropping this line silently removes that boundary.
92
+ user=${instanceUser(inst)}
93
+ environment=HOME="${path.posix.join(etcBase, inst.slug)}",PGDATABASE="${inst.db_name || inst.slug}",PORT="${inst.port}"
94
+ autostart=true
95
+ autorestart=true
96
+ startsecs=3
97
+ stdout_logfile=/var/log/supervisor/${inst.slug}.log
98
+ redirect_stderr=true
99
+ `;
100
+ }
101
+
102
+ const supervisord = {
103
+ key: 'supervisord',
104
+
105
+ installService(inst, { exec, writeFile }) {
106
+ writeFile(supervisorConfPath(inst), supervisorProgram(inst), { mode: 0o644 });
107
+ exec('supervisorctl reread');
108
+ exec('supervisorctl update');
109
+ exec(`supervisorctl start ${inst.slug}`);
110
+ },
111
+
112
+ // supervisord supervises processes, not schedules — it has no timer concept. Rather
113
+ // than re-implement cron badly, install nothing and hand the operator the exact line
114
+ // (ADR 0306). LOUD on purpose: a self-host with no automatic DB backup must not be a
115
+ // thing anyone discovers after they need one.
116
+ installBackup(inst, { log }) {
117
+ // The env the dump needs is LIFTED from the systemd unit rather than retyped, so the
118
+ // crontab line and the unit cannot drift and the variable names live in one place.
119
+ const env = backupService(inst).split('\n')
120
+ .filter((l) => l.startsWith('Environment='))
121
+ .map((l) => l.slice('Environment='.length))
122
+ .join(' ');
123
+ log(` (no backup schedule: ${supervisord.key} has no timers — add this to a crontab instead)`);
124
+ log(` 0 4 * * * ${env} /bin/bash ${backupScriptPath(inst)}`);
125
+ return { installed: false, reason: 'supervisord has no timer concept' };
126
+ },
127
+
128
+ restartService(inst, { exec }) {
129
+ exec(`supervisorctl restart ${inst.slug}`);
130
+ },
131
+
132
+ removeService(inst, { exec }) {
133
+ exec(`supervisorctl stop ${inst.slug}`, { allowFail: true });
134
+ exec(`rm -f ${supervisorConfPath(inst)}`, { allowFail: true });
135
+ exec('supervisorctl reread', { allowFail: true });
136
+ exec('supervisorctl update', { allowFail: true });
137
+ },
138
+ };
139
+
140
+ const MANAGERS = { systemd, supervisord };
141
+
142
+ // Was this host booted under systemd? /run/systemd/system is the check sd_booted(3)
143
+ // makes and the one systemd documents for exactly this question — it exists only when
144
+ // systemd is PID 1, so a container answers no without us guessing at container-ness.
145
+ function systemdIsBooted(fsImpl) {
146
+ try { return fsImpl.existsSync('/run/systemd/system'); } catch { return false; }
147
+ }
148
+
149
+ // Explicit → probed → default. An unrecognised knob value throws rather than falling
150
+ // back: silently supervising an instance a different way than the operator asked is
151
+ // worse than refusing to provision.
152
+ function resolveProcessManager({ env = process.env, fsImpl = fs } = {}) {
153
+ const want = String(env.PROVISION_PROCESS_MANAGER || '').trim().toLowerCase();
154
+ if (want) {
155
+ if (MANAGERS[want]) return MANAGERS[want];
156
+ throw new Error(`PROVISION_PROCESS_MANAGER=${want} is not a known process manager (${Object.keys(MANAGERS).join(', ')})`);
157
+ }
158
+ return systemdIsBooted(fsImpl) ? systemd : supervisord;
159
+ }
160
+
161
+ module.exports = {
162
+ resolveProcessManager,
163
+ supervisorConfPath,
164
+ supervisorProgram,
165
+ supervisord,
166
+ systemd,
167
+ };
@@ -12,7 +12,7 @@
12
12
  const path = require('node:path');
13
13
  const { CONFIG, REPO_ROOT } = require('./provision-config.js');
14
14
  const { dbName, deployKeyPath, deployKeyTitle, parseTargetRef, resolveOwnerGithubToken } = require('./provision-repo.js');
15
- const { backupServicePath, backupTimerPath, backupUnitName, serviceUnitPath } = require('./provision-units.js');
15
+ const { resolveProcessManager } = require('./provision-process.js');
16
16
  const { caddySnippetPath, dnsReleaseEnv } = require('./provision-net.js');
17
17
  const apexMod = require('./instance-apex.js');
18
18
  const { isReservedHostname } = require('../../modules/provisioning/provisioning.js');
@@ -29,13 +29,8 @@ async function teardownInstance(inst, deps) {
29
29
  log(`teardown ${inst.slug} (status=${inst.status})`);
30
30
  if (apply) await provisioning.setInstanceStatus(db, inst.id, 'tearing_down');
31
31
  if (inst.hosting_shape !== 'dedicated') {
32
- boxExec(`${sudoP}systemctl disable --now ${inst.slug}`, { allowFail: true });
33
- boxExec(`${sudoP}rm -f ${serviceUnitPath(inst)}`, { allowFail: true });
34
- // Stop + remove the nightly backup units too (task 2213; the dir + dumps stay — precious data, like the DB below), then reset-failed (task 1003940): a removed unit's FAILED state outlives its file, so `systemctl --failed` listed torn-down ghosts for weeks.
35
- boxExec(`${sudoP}systemctl disable --now ${backupUnitName(inst)}.timer`, { allowFail: true });
36
- boxExec(`${sudoP}rm -f ${backupServicePath(inst)} ${backupTimerPath(inst)}`, { allowFail: true });
37
- boxExec(`${sudoP}systemctl daemon-reload`, { allowFail: true });
38
- boxExec(`${sudoP}systemctl reset-failed ${inst.slug}.service ${backupUnitName(inst)}.service`, { allowFail: true });
32
+ // Stop + remove the service AND the nightly backup units (task 2213; the dir + dumps stay — precious data, like the DB below). Whoever supervises the instance owns its removal (ADR 0306) — the systemd manager keeps the reset-failed step task 1003940 added, since a removed unit's FAILED state outlives its file and `systemctl --failed` listed torn-down ghosts for weeks.
33
+ (deps.processManager || resolveProcessManager()).removeService(inst, { exec: boxExec, sudoP });
39
34
  // The site snippet standup step 6 wrote: while it stays, Caddy proxies the hostname to
40
35
  // a port nothing listens on and every visitor gets a 502 (task 1002897). Not precious.
41
36
  boxExec(`${sudoP}rm -f ${caddySnippetPath(inst)}`, { allowFail: true });
@@ -53,12 +53,17 @@ const { CONFIG, MANIFEST_UA, MANIFEST_VERIFY_INTERVAL_MS, MANIFEST_VERIFY_TRIES,
53
53
  const { APP_USER, alreadyScaffolded, buildCorePinRefreshCommit, coreCheckoutHasGit, dbCreateCmd, dbName, dbRoleCmd, dbRoleCmdShown, dbRoleGrantsCmd, ensureInstanceUserCmd, generateDbPassword, grantInstanceRepoReadCmd, instanceDbRole, instanceStateDir, instanceUser, deployKeyPath, deployKeyTitle, ensurePrivateRepoAccess, installedCorePackDir, installedCoreTarball, instanceInitSpec, instanceRepoRemote, migrateCmd, onboardMode, ownerLoginOf, parseTargetRef, planCorePinRefresh, refreshStandaloneCorePin, resolveOwnerGithubToken, resolveVendorableCoreTarball, safeVersionLabel, scaffoldStandaloneRepo, seedFirstVersionCmd, standaloneInstallCmd, standaloneMigrateCmd, standalonePullCmd, standaloneRegenDocsCmd, standaloneRoot } = require('./provision-repo.js');
54
54
  const { backupScriptPath, backupService, backupServicePath, backupTimer, backupTimerPath, backupUnitName, instanceManifestCmd, originEnvVarsFor, serviceUnit, serviceUnitPath, settingsConsumed, settingsEnvVarsFor, upsertEnvVars, webEnvBody, webEnvPath } = require('./provision-units.js');
55
55
  const { caddyBlock, caddySnippetPath, checkCaddyWiring, checkDnsTokenScope, classifyZoneScope, dnsReleaseEnv, dnsUpsertEnv, federateInstance, federationHubOrigin, healthzCmd, identityCmd, identityVerdict, shouldFederate, zoneForDomain } = require('./provision-net.js');
56
+ const { resolveProcessManager } = require('./provision-process.js');
56
57
  const { capacityPreflight, statusCapacityLine } = require('./provision-capacity.js');
57
58
  const { installedCoreVersion, runnerBanner } = require('./runner-drift.js');
58
59
  const { teardownInstance } = require('./provision-teardown.js');
59
60
  const { disconnectInstance } = require('./provision-disconnect.js');
60
61
  const { coreUpgradeInstance, snapshotAllCoreVersions } = require('./provision-core-upgrade.js'); // the owner's deploy door, ADR 0293
61
62
 
63
+ // WHAT supervises an instance's service (ADR 0306). systemd on every box we run — the
64
+ // seam exists so a plane without systemd is not a dead end. Injectable for tests.
65
+ const pmFor = (deps) => (deps && deps.processManager) || resolveProcessManager();
66
+
62
67
  function makeExec({ apply, log = console.log, remoteHost = null, remoteCwd = null, sshOpts = '' }) {
63
68
  // `shown` (task 1003140): what to LOG in place of the command text — for a command
64
69
  // that carries a secret (a remote file placement embeds the base64 body), so the
@@ -435,12 +440,11 @@ async function provisionInstance(inst, deps) {
435
440
  if (local) boxWriteFile(webEnvPath(inst), webEnvBody(inst, { federation, ownerLogin: await ownerLoginOf(inst, deps.db), dbPassword }), { mode: 0o600, sudo: true });
436
441
  else log(' (baked into cloud-init)');
437
442
 
438
- // ── step 4: systemd unit + enable ─────────────────────────────────────────
439
- log(' [service] install + enable systemd unit');
443
+ // ── step 4: install + enable the instance's service ───────────────────────
444
+ const pm = pmFor(deps);
445
+ log(` [service] install + enable the ${pm.key} service`);
440
446
  if (local) {
441
- boxWriteFile(serviceUnitPath(inst), serviceUnit(inst), { mode: 0o644, sudo: true });
442
- boxExec(`${sudoP}systemctl daemon-reload`);
443
- boxExec(`${sudoP}systemctl enable --now ${inst.slug}`);
447
+ pm.installService(inst, { exec: boxExec, writeFile: boxWriteFile, sudoP, log });
444
448
  } else { log(' (unit baked into cloud-init)'); }
445
449
 
446
450
  // ── step 4b: nightly DB backup — dir + consumer-layout timer/service (task 2213) ──
@@ -451,11 +455,7 @@ async function provisionInstance(inst, deps) {
451
455
  // overwrites, `enable --now` is idempotent. Dedicated bakes the same into cloud-init.
452
456
  log(' [backup] create backup dir + install + enable nightly DB backup timer');
453
457
  if (local) {
454
- boxExec(`${sudoP}install -d -o ${APP_USER} -g ${APP_USER} -m 0750 ${CONFIG.backupDir}`);
455
- boxWriteFile(backupServicePath(inst), backupService(inst), { mode: 0o644, sudo: true });
456
- boxWriteFile(backupTimerPath(inst), backupTimer(inst), { mode: 0o644, sudo: true });
457
- boxExec(`${sudoP}systemctl daemon-reload`);
458
- boxExec(`${sudoP}systemctl enable --now ${backupUnitName(inst)}.timer`);
458
+ pm.installBackup(inst, { exec: boxExec, writeFile: boxWriteFile, sudoP, log });
459
459
  } else { log(' (backup dir + units baked into cloud-init)'); }
460
460
 
461
461
  // ── step 5: DNS — Cloudflare A-record UPSERT for the instance's domain (P4) ──
@@ -778,7 +778,7 @@ async function domainAttachInstance(inst, deps) {
778
778
  boxWriteFile(webEnvPath(inst), upsertEnvVars(currentEnv, origins), { mode: 0o600, sudo: true });
779
779
  }
780
780
  log(' [service] restart to pick up the new origins');
781
- boxExec(`${sudoP}systemctl restart ${inst.slug}`);
781
+ pmFor(deps).restartService(inst, { exec: boxExec, sudoP });
782
782
 
783
783
  // DNS A-record UPSERT — mirrors provisionInstance step 5 (control-plane exec only:
784
784
  // the hook needs the Cloudflare token, which never leaves the control plane).
@@ -889,7 +889,7 @@ async function settingsApplyInstance(inst, deps) {
889
889
  const current = String((read && read.stdout) || '');
890
890
  if (!apply) {
891
891
  log(` [config] would patch ${Object.keys(vars).join(', ')} in ${webEnvPath(inst)} in place (every other line untouched)`);
892
- boxExec(`${sudoP}systemctl restart ${inst.slug}`);
892
+ pmFor(deps).restartService(inst, { exec: boxExec, sudoP });
893
893
  boxExec(healthzCmd(inst), { allowFail: true });
894
894
  log(` ✓ would restart ${inst.slug} with the new settings (dry-run — nothing ran)`);
895
895
  return { ok: true, healthy: null, consumed: null };
@@ -901,7 +901,7 @@ async function settingsApplyInstance(inst, deps) {
901
901
  log(' [config] patch the settings lines of web.env in place (creds + origins untouched)');
902
902
  boxWriteFile(webEnvPath(inst), upsertEnvVars(current, vars), { mode: 0o600, sudo: true });
903
903
  log(' [service] restart so the memoized branding pack re-reads them');
904
- boxExec(`${sudoP}systemctl restart ${inst.slug}`);
904
+ pmFor(deps).restartService(inst, { exec: boxExec, sudoP });
905
905
  } catch (e) {
906
906
  // ok:false, never a throw: the retry re-reads the file (the upsert is
907
907
  // idempotent) and restarts again; the instance's status is left alone.
@@ -991,14 +991,14 @@ async function restartInstance(inst, deps) {
991
991
  return { ok: false, error: `restart is not automated for a dedicated droplet — run 'systemctl restart ${inst.slug}' on that machine` };
992
992
  }
993
993
  if (!apply) {
994
- boxExec(`${sudoP}systemctl restart ${inst.slug}`);
994
+ pmFor(deps).restartService(inst, { exec: boxExec, sudoP });
995
995
  boxExec(healthzCmd(inst), { allowFail: true });
996
996
  log(` ✓ would restart ${inst.slug} and wait for it to answer (dry-run — nothing ran)`);
997
997
  return { ok: true, healthy: null };
998
998
  }
999
999
  try {
1000
1000
  log(' [service] restart');
1001
- boxExec(`${sudoP}systemctl restart ${inst.slug}`);
1001
+ pmFor(deps).restartService(inst, { exec: boxExec, sudoP });
1002
1002
  } catch (e) {
1003
1003
  // ok:false, never a throw — the retry simply restarts again (idempotent).
1004
1004
  return { ok: false, error: `could not restart ${inst.slug}: ${(e && e.message) || e}` };
@@ -1142,7 +1142,7 @@ async function placeManifestCreds(inst, mrow, deps) {
1142
1142
  // it is harmless and heals once the owner grants the permissions + installs.
1143
1143
  appId: creds.appId, appPem: creds.appPem,
1144
1144
  }), { mode: 0o600, sudo: true });
1145
- boxExec(`${sudoP}systemctl restart ${inst.slug}`);
1145
+ pmFor(deps).restartService(inst, { exec: boxExec, sudoP });
1146
1146
 
1147
1147
  // 4) verify /healthz reports auth_configured=true (only meaningful under --apply).
1148
1148
  // PATIENT (F23): the child must boot before it can answer; losing this race
package/src/module-api.js CHANGED
@@ -71,7 +71,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
71
71
  // there. scripts/gds/bump-version.js still rewrites the literal below; it appends
72
72
  // the entry to that file. Look for a version's history there, not here.
73
73
  // ---------------------------------------------------------------------------
74
- const CORE_VERSION = '1.19.853'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
74
+ const CORE_VERSION = '1.19.855'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
75
75
 
76
76
  // A namespaced logger so a module's log lines are attributable + consistent.
77
77
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -0,0 +1,115 @@
1
+ // tests/box_sweep_docs.mjs — the box sweep docs must not invert against box.js
2
+ // a third time (task 1003823).
3
+ //
4
+ // HISTORY, because it is the whole reason this file exists. The idle sweep has
5
+ // changed direction twice:
6
+ //
7
+ // task 804 — sweep-idle went from park to a direct deprovision, and
8
+ // task 1001135 dragged four doc surfaces along with it.
9
+ // task 1002726 — the CODE went BACK to park-and-keep-the-snapshot, because
10
+ // annihilating an idle box took the builder's uncommitted work
11
+ // with it. The four doc surfaces were NOT walked back.
12
+ //
13
+ // So for months the routine's own SKILL.md told an agent "your single job is to
14
+ // DESTROY" while cmdSweepIdle called parkOne. The danger is specific and worth
15
+ // naming: an agent that believes it is supposed to destroy, and finds the sweep
16
+ // merely parking, is being invited to reach for `box.js deprovision` to finish a
17
+ // job it was never given — which is the one path that can silently discard a
18
+ // builder's work.
19
+ //
20
+ // This guard is therefore NOT a spell-check. It asserts the one relationship
21
+ // that matters: WHILE cmdSweepIdle parks, the docs may not say it destroys.
22
+ // If a future task legitimately moves the idle path back to a destroy, this test
23
+ // goes green on its own as soon as the code changes — it is pinned to the code,
24
+ // not to a preferred wording.
25
+
26
+ import assert from 'node:assert/strict';
27
+ import fs from 'node:fs';
28
+ import path from 'node:path';
29
+ import { test } from 'node:test';
30
+ import { fileURLToPath } from 'node:url';
31
+
32
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
33
+ const read = (p) => fs.readFileSync(path.join(ROOT, p), 'utf8');
34
+
35
+ const BOX_JS = 'scripts/gds/box.js';
36
+ const IDLE_SKILL = '.claude/scheduled-tasks/box-idle-suspend/SKILL.md';
37
+ const DORMANT_SKILL = '.claude/scheduled-tasks/box-dormant-reclaim/SKILL.md';
38
+ const RECIPE = 'docs/recipes/builder-box-lifecycle.md';
39
+
40
+ // Comments are stripped before scanning prose: each surface deliberately keeps a
41
+ // sentence SAYING which model it used to describe, and a naive substring check
42
+ // would read that history as the claim still being made.
43
+ const stripHtmlComments = (md) => md.replace(/<!--[\s\S]*?-->/g, '');
44
+
45
+ // The body of cmdSweepIdle, so "what the sweep does" is read from the function
46
+ // itself rather than from a header comment that can rot the same way the docs did.
47
+ function cmdSweepIdleBody() {
48
+ const src = read(BOX_JS);
49
+ const start = src.indexOf('async function cmdSweepIdle(');
50
+ assert.ok(start > 0, `cmdSweepIdle not found in ${BOX_JS} — this guard is reading the wrong file`);
51
+ const next = src.indexOf('\nasync function ', start + 1);
52
+ return src.slice(start, next > 0 ? next : src.length);
53
+ }
54
+
55
+ test('the idle sweep parks — that is the fact the docs are measured against', () => {
56
+ const body = cmdSweepIdleBody();
57
+ assert.match(body, /parkOne\(/, 'cmdSweepIdle no longer calls parkOne');
58
+ assert.doesNotMatch(body, /deprovisionOne\(/,
59
+ 'cmdSweepIdle calls deprovisionOne — the idle path destroys again. That is allowed, '
60
+ + 'but the docs below must be rewritten to match BEFORE this guard is relaxed.');
61
+ });
62
+
63
+ test('no box-sweep doc claims the IDLE sweep destroys, while the code parks', () => {
64
+ // Sentences that assert a destroy as the idle sweep's OUTCOME. Each pattern is
65
+ // anchored on the idle path specifically, because "destroy the droplet" is a
66
+ // true statement about a park (the droplet does go; the snapshot stays) and
67
+ // "destroy the snapshot" is a true statement about sweep-dormant.
68
+ const lies = [
69
+ /idle[- ]deprovision/i,
70
+ /idle box is fully DESTROYED/i,
71
+ /destroy the droplet \*\*AND\*\* (?:its )?snapshot/i,
72
+ /droplet \+ snapshot destroyed/i,
73
+ /idle no longer feeds it/i,
74
+ /no longer parks/i,
75
+ /there is no snapshot to wake from/i,
76
+ /single job is to DESTROY/i,
77
+ ];
78
+
79
+ for (const file of [IDLE_SKILL, DORMANT_SKILL, RECIPE]) {
80
+ const prose = stripHtmlComments(read(file));
81
+ for (const lie of lies) {
82
+ assert.doesNotMatch(prose, lie,
83
+ `${file} still describes the pre-task-1002726 destroy model (${lie}). `
84
+ + 'cmdSweepIdle calls parkOne and keeps the snapshot — see task 1003823.');
85
+ }
86
+ }
87
+ });
88
+
89
+ test('the idle skill quotes the strings the worker actually prints', () => {
90
+ // Step 3 of the skill orders a VERBATIM relay, so a drifted string here makes
91
+ // the agent either fabricate a line or stall. These are the three the worker emits.
92
+ const skill = read(IDLE_SKILL);
93
+ const body = cmdSweepIdleBody();
94
+
95
+ for (const s of ['would park', 'snapshot kept, compute stopped', 'done — parked=']) {
96
+ assert.ok(body.includes(s), `cmdSweepIdle no longer prints ${JSON.stringify(s)}`);
97
+ assert.ok(skill.includes(s),
98
+ `${IDLE_SKILL} does not quote ${JSON.stringify(s)}, which the worker prints and `
99
+ + 'step 3 tells the agent to relay verbatim.');
100
+ }
101
+
102
+ assert.ok(!/done — deprovisioned=/.test(skill),
103
+ `${IDLE_SKILL} tells the agent to relay "done — deprovisioned=", a line the worker never emits.`);
104
+ });
105
+
106
+ test('every surface names sweep-dormant as the only automated snapshot delete', () => {
107
+ // The safety property box.js implements in prose: full reclaim warns first, so
108
+ // no automated path takes uncommitted work silently. A reader who misses this
109
+ // is the reader who reaches for deprovision.
110
+ for (const file of [IDLE_SKILL, DORMANT_SKILL, RECIPE]) {
111
+ assert.match(stripHtmlComments(read(file)), /only automated path that destroys a snapshot|only automated snapshot delete/i,
112
+ `${file} never tells the reader that sweep-dormant is the only automated path that `
113
+ + 'destroys a snapshot (and that it warns first) — the safety property this subsystem rests on.');
114
+ }
115
+ });
@@ -34,6 +34,12 @@ const reEsc = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
34
34
  // (the resolution rule itself is covered directly, with an injected probe).
35
35
  process.env.PROVISION_GIT_SSH_ALIAS = 'github-deploy';
36
36
 
37
+ // Same reasoning for supervision (task 1003997, ADR 0306): the manager is probed from
38
+ // /run/systemd/system when nothing pins it, so pin systemd here — the composition
39
+ // assertions below are about the runbook, not about whether the test box boots systemd.
40
+ // The probe itself is covered directly, with an injected fs.
41
+ process.env.PROVISION_PROCESS_MANAGER = 'systemd';
42
+
37
43
  let passed = 0, failed = 0;
38
44
  function t(name, fn) {
39
45
  try { fn(); passed++; console.log(` PASS ${name}`); }
@@ -576,6 +582,113 @@ t('instanceRepoRemotes: the accept-set a stored remote is validated against, pro
576
582
  } finally { process.env.PROVISION_GIT_SSH_ALIAS = saved; }
577
583
  });
578
584
 
585
+ // ── the process-manager seam (task 1003997, ADR 0306) ───────────────────────
586
+ const PM = createRequire(import.meta.url)('../scripts/gds/provision-process.js');
587
+
588
+ // A recording io: the same { exec, writeFile, sudoP, log } shape the runbook hands over.
589
+ function recordIo({ sudoP = '' } = {}) {
590
+ const cmds = [], files = [], logs = [];
591
+ return {
592
+ cmds, files, logs, sudoP,
593
+ exec: (cmd, opts) => cmds.push(opts && opts.allowFail ? `${cmd} [allowFail]` : cmd),
594
+ writeFile: (p, body, opts) => files.push({ path: p, body, opts }),
595
+ log: (m) => logs.push(m),
596
+ };
597
+ }
598
+ const INST = { slug: 'demo', hosting_shape: 'standalone', port: 3003, db_name: 'demo' };
599
+
600
+ t('resolveProcessManager: explicit knob wins, then the /run/systemd/system probe', () => {
601
+ const booted = { existsSync: (p) => p === '/run/systemd/system' };
602
+ const bare = { existsSync: () => false };
603
+ assert.equal(PM.resolveProcessManager({ env: { PROVISION_PROCESS_MANAGER: 'supervisord' }, fsImpl: booted }).key,
604
+ 'supervisord', 'the knob overrides even a systemd host');
605
+ assert.equal(PM.resolveProcessManager({ env: {}, fsImpl: booted }).key, 'systemd',
606
+ 'the live box boots systemd, so it keeps systemd');
607
+ assert.equal(PM.resolveProcessManager({ env: {}, fsImpl: bare }).key, 'supervisord',
608
+ 'a container boots no systemd');
609
+ assert.throws(() => PM.resolveProcessManager({ env: { PROVISION_PROCESS_MANAGER: 'runit' }, fsImpl: booted }),
610
+ /PROVISION_PROCESS_MANAGER/, 'an unknown manager is LOUD, never a silent fallback');
611
+ });
612
+
613
+ t('systemd manager: emits byte-identical commands + unit paths to the inline strings it replaced', () => {
614
+ const io = recordIo({ sudoP: 'sudo ' });
615
+ PM.systemd.installService(INST, io);
616
+ assert.deepEqual(io.cmds, ['sudo systemctl daemon-reload', 'sudo systemctl enable --now demo']);
617
+ assert.equal(io.files[0].path, '/etc/systemd/system/demo.service');
618
+ assert.deepEqual(io.files[0].opts, { mode: 0o644, sudo: true });
619
+
620
+ const io2 = recordIo({ sudoP: 'sudo ' });
621
+ PM.systemd.installBackup(INST, io2);
622
+ // The backup DIR moved in here with the schedule it serves: a manager that installs no
623
+ // schedule must not create it either. Order is unchanged — dir, units, reload, enable.
624
+ assert.match(io2.cmds[0], /^sudo install -d -o \S+ -g \S+ -m 0750 /);
625
+ assert.deepEqual(io2.cmds.slice(1), ['sudo systemctl daemon-reload', 'sudo systemctl enable --now demo-db-backup.timer']);
626
+ assert.deepEqual(io2.files.map((f) => f.path),
627
+ ['/etc/systemd/system/demo-db-backup.service', '/etc/systemd/system/demo-db-backup.timer']);
628
+
629
+ const io3 = recordIo({ sudoP: 'sudo ' });
630
+ PM.systemd.restartService(INST, io3);
631
+ assert.deepEqual(io3.cmds, ['sudo systemctl restart demo']);
632
+
633
+ const io4 = recordIo({ sudoP: 'sudo ' });
634
+ PM.systemd.removeService(INST, io4);
635
+ assert.deepEqual(io4.cmds, [
636
+ 'sudo systemctl disable --now demo [allowFail]',
637
+ 'sudo rm -f /etc/systemd/system/demo.service [allowFail]',
638
+ 'sudo systemctl disable --now demo-db-backup.timer [allowFail]',
639
+ 'sudo rm -f /etc/systemd/system/demo-db-backup.service /etc/systemd/system/demo-db-backup.timer [allowFail]',
640
+ 'sudo systemctl daemon-reload [allowFail]',
641
+ 'sudo systemctl reset-failed demo.service demo-db-backup.service [allowFail]',
642
+ ], 'including reset-failed — a removed unit\'s FAILED state outlives its file (task 1003940)');
643
+ });
644
+
645
+ t('systemd manager: an UNPRIVILEGED run drops the sudo prefix and nothing else', () => {
646
+ const io = recordIo({ sudoP: '' });
647
+ PM.systemd.installService(INST, io);
648
+ assert.deepEqual(io.cmds, ['systemctl daemon-reload', 'systemctl enable --now demo']);
649
+ });
650
+
651
+ t('supervisord manager: a program config, no systemd, and the ADR 0281 user= carried over', () => {
652
+ const io = recordIo();
653
+ PM.supervisord.installService(INST, io);
654
+ const conf = io.files[0];
655
+ assert.equal(conf.path, '/etc/supervisor/conf.d/demo.conf');
656
+ assert.match(conf.body, /^\[program:demo\]$/m);
657
+ assert.match(conf.body, /^user=/m, 'isolation is restated per manager, never dropped (ADR 0306)');
658
+ assert.match(conf.body, /platform-server\.js/, 'it runs the same entrypoint the unit did');
659
+ assert.deepEqual(io.cmds, ['supervisorctl reread', 'supervisorctl update', 'supervisorctl start demo']);
660
+ assert.ok(!io.cmds.some((c) => /systemctl/.test(c)), 'nothing reaches for systemd');
661
+
662
+ const io2 = recordIo();
663
+ PM.supervisord.restartService(INST, io2);
664
+ assert.deepEqual(io2.cmds, ['supervisorctl restart demo']);
665
+
666
+ const io3 = recordIo();
667
+ PM.supervisord.removeService(INST, io3);
668
+ assert.deepEqual(io3.cmds, [
669
+ 'supervisorctl stop demo [allowFail]',
670
+ 'rm -f /etc/supervisor/conf.d/demo.conf [allowFail]',
671
+ 'supervisorctl reread [allowFail]',
672
+ 'supervisorctl update [allowFail]',
673
+ ]);
674
+ });
675
+
676
+ t('supervisord manager: installs NO backup schedule and says so with the crontab line', () => {
677
+ const io = recordIo();
678
+ const r = PM.supervisord.installBackup(INST, io);
679
+ assert.equal(r.installed, false, 'supervisord supervises processes, not schedules');
680
+ assert.deepEqual(io.files, [], 'no unit files invented');
681
+ assert.deepEqual(io.cmds, [], 'and no scheduler commands either');
682
+ assert.ok(io.logs.some((l) => /crontab|cron/i.test(l)),
683
+ 'the skip is LOUD and hands the operator the line to add (ADR 0306)');
684
+ assert.ok(io.logs.some((l) => /db-backup-nightly\.sh/.test(l)), 'naming the actual script');
685
+ // The env is lifted from the systemd unit, so the line the operator pastes carries the
686
+ // same DB and dir the timer would have used — and cannot drift from it.
687
+ const cronLine = io.logs.find((l) => /db-backup-nightly\.sh/.test(l));
688
+ assert.ok(/_BACKUP_DB=demo\b/.test(cronLine) && /_BACKUP_DIR=\S+/.test(cronLine),
689
+ 'the dump env rides along, taken from the unit rather than retyped');
690
+ });
691
+
579
692
  t('instanceInitSpec: builds a VALID greenfield init spec from the row + owner login', () => {
580
693
  const spec = P.instanceInitSpec(
581
694
  { slug: 'demo', target_ref: 'example-owner/demo', domain: 'demo.cloudbongos.com', port: 3003 },