@bongos/core 1.21.85 → 1.21.87
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 +32 -17
- package/docs/architecture.md +1 -1
- package/docs/design/project-startup-direction.md +58 -1
- package/docs/module-api-changelog.md +4 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +16 -0
- package/scripts/gds/start-goals.js +102 -0
- package/scripts/gds/start.js +47 -11
- package/src/bongos/module-store.js +101 -5
- package/src/bongos/routes/modules.js +7 -1
- package/src/module-api.js +1 -1
- package/tests/module_store_instance_root.mjs +163 -0
- package/tests/start_goal_first.mjs +90 -0
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.21.
|
|
6
|
-
"core_contract": "1.21.
|
|
7
|
-
"source_commit": "
|
|
5
|
+
"core_version": "1.21.87",
|
|
6
|
+
"core_contract": "1.21.87",
|
|
7
|
+
"source_commit": "3a000da66e4b7ed910b59eb0e1843e81a7e3f7fd",
|
|
8
8
|
"source_ref": "HEAD",
|
|
9
|
-
"built_at": "2026-10-
|
|
9
|
+
"built_at": "2026-10-09T05:06:35.756Z",
|
|
10
10
|
"redaction": {
|
|
11
11
|
"model": "docs-redacted+functional-verbatim",
|
|
12
12
|
"docs_redacted": 590,
|
|
13
13
|
"agent_docs_stubbed": 28,
|
|
14
|
-
"functional_verbatim":
|
|
14
|
+
"functional_verbatim": 3000,
|
|
15
15
|
"rules": 3,
|
|
16
16
|
"gate_literals": 3,
|
|
17
17
|
"gate": "passed"
|
|
18
18
|
},
|
|
19
|
-
"file_count":
|
|
20
|
-
"tree_sha256": "
|
|
19
|
+
"file_count": 3619,
|
|
20
|
+
"tree_sha256": "94cb33a270055b9c3e008a1a26ad2e66038572aae76148fccb2f9b3bf2bbecf9",
|
|
21
21
|
"files": [
|
|
22
22
|
{
|
|
23
23
|
"path": ".claude/skills/ask-for-help/SKILL.md",
|
|
@@ -2327,7 +2327,7 @@
|
|
|
2327
2327
|
{
|
|
2328
2328
|
"path": "docs/architecture.md",
|
|
2329
2329
|
"mode": "0000644",
|
|
2330
|
-
"sha256": "
|
|
2330
|
+
"sha256": "0dac6b0ee55d7836cf6352c695f592000bfd61d0c3ec06d9c17d37b476b1134a"
|
|
2331
2331
|
},
|
|
2332
2332
|
{
|
|
2333
2333
|
"path": "docs/branding-contract.md",
|
|
@@ -2432,7 +2432,7 @@
|
|
|
2432
2432
|
{
|
|
2433
2433
|
"path": "docs/design/project-startup-direction.md",
|
|
2434
2434
|
"mode": "0000644",
|
|
2435
|
-
"sha256": "
|
|
2435
|
+
"sha256": "8cac194bfa4e7e0cfa6785ff72fd141980eb2abed6497ce56e42585c2f4a918c"
|
|
2436
2436
|
},
|
|
2437
2437
|
{
|
|
2438
2438
|
"path": "docs/design/projects-hub-direction-v2.md",
|
|
@@ -2862,7 +2862,7 @@
|
|
|
2862
2862
|
{
|
|
2863
2863
|
"path": "docs/module-api-changelog.md",
|
|
2864
2864
|
"mode": "0000644",
|
|
2865
|
-
"sha256": "
|
|
2865
|
+
"sha256": "39126044930a9015689ba736100232d99d1d13d43d723ec95a15dbe505c4390d"
|
|
2866
2866
|
},
|
|
2867
2867
|
{
|
|
2868
2868
|
"path": "docs/modules-contract.md",
|
|
@@ -10097,12 +10097,12 @@
|
|
|
10097
10097
|
{
|
|
10098
10098
|
"path": "package-lock.json",
|
|
10099
10099
|
"mode": "0000644",
|
|
10100
|
-
"sha256": "
|
|
10100
|
+
"sha256": "6953a80922eac656bc533d17e921f49228e939cacb64da8e56d1a90fa5f358db"
|
|
10101
10101
|
},
|
|
10102
10102
|
{
|
|
10103
10103
|
"path": "package.json",
|
|
10104
10104
|
"mode": "0000644",
|
|
10105
|
-
"sha256": "
|
|
10105
|
+
"sha256": "be88b7b575574dc07cbf071c639c419a4365cd89e7ed0b7bba058de98982b443"
|
|
10106
10106
|
},
|
|
10107
10107
|
{
|
|
10108
10108
|
"path": "public-docs/index.html",
|
|
@@ -10122,7 +10122,7 @@
|
|
|
10122
10122
|
{
|
|
10123
10123
|
"path": "release-notes.json",
|
|
10124
10124
|
"mode": "0000644",
|
|
10125
|
-
"sha256": "
|
|
10125
|
+
"sha256": "ba7ae503bc8f34ef8244b2fb989462283e4128b6ea03ac066d050acfb35edbc5"
|
|
10126
10126
|
},
|
|
10127
10127
|
{
|
|
10128
10128
|
"path": "scripts/cron/style_guide_audit.sh",
|
|
@@ -11734,10 +11734,15 @@
|
|
|
11734
11734
|
"mode": "0000644",
|
|
11735
11735
|
"sha256": "9f6ce80e5325f4cfe68cfa6cb82bec52d594af7e461b6734724819b23cbb0d37"
|
|
11736
11736
|
},
|
|
11737
|
+
{
|
|
11738
|
+
"path": "scripts/gds/start-goals.js",
|
|
11739
|
+
"mode": "0000644",
|
|
11740
|
+
"sha256": "8c4e1f1c7871c82b0fefd5402c93888831543dcd776a1e761e7b1238b3afafaa"
|
|
11741
|
+
},
|
|
11737
11742
|
{
|
|
11738
11743
|
"path": "scripts/gds/start.js",
|
|
11739
11744
|
"mode": "0000644",
|
|
11740
|
-
"sha256": "
|
|
11745
|
+
"sha256": "181f07b8d62f03ae001d08dd5e5b15a65b33f512fb548e55401b97f7f484f31f"
|
|
11741
11746
|
},
|
|
11742
11747
|
{
|
|
11743
11748
|
"path": "scripts/gds/status.js",
|
|
@@ -12137,7 +12142,7 @@
|
|
|
12137
12142
|
{
|
|
12138
12143
|
"path": "src/bongos/module-store.js",
|
|
12139
12144
|
"mode": "0000644",
|
|
12140
|
-
"sha256": "
|
|
12145
|
+
"sha256": "c45fffd2d1320cfadd2177421e8b58643676a71fa1bac7a01e4219c60bfe20b1"
|
|
12141
12146
|
},
|
|
12142
12147
|
{
|
|
12143
12148
|
"path": "src/bongos/module-submissions.js",
|
|
@@ -12292,7 +12297,7 @@
|
|
|
12292
12297
|
{
|
|
12293
12298
|
"path": "src/bongos/routes/modules.js",
|
|
12294
12299
|
"mode": "0000644",
|
|
12295
|
-
"sha256": "
|
|
12300
|
+
"sha256": "aea0a0f28621500bee51e4fec8e794139cf90c3360fdc55caecbbd5a5fc82b69"
|
|
12296
12301
|
},
|
|
12297
12302
|
{
|
|
12298
12303
|
"path": "src/bongos/routes/my-sessions.js",
|
|
@@ -12372,7 +12377,7 @@
|
|
|
12372
12377
|
{
|
|
12373
12378
|
"path": "src/module-api.js",
|
|
12374
12379
|
"mode": "0000644",
|
|
12375
|
-
"sha256": "
|
|
12380
|
+
"sha256": "3ba47cbdc5c1c0dc86af2cf1302a6ffc3c9966a7646f88bd2a004f5dd67b0ded"
|
|
12376
12381
|
},
|
|
12377
12382
|
{
|
|
12378
12383
|
"path": "src/module-loader/catalog.js",
|
|
@@ -15754,6 +15759,11 @@
|
|
|
15754
15759
|
"mode": "0000644",
|
|
15755
15760
|
"sha256": "43828c1a95652180d558e7676cc2053d0599a61c0edf4cd1ab100820fc7de493"
|
|
15756
15761
|
},
|
|
15762
|
+
{
|
|
15763
|
+
"path": "tests/module_store_instance_root.mjs",
|
|
15764
|
+
"mode": "0000644",
|
|
15765
|
+
"sha256": "aa66cdb054eeaef8c3c8babf5238f029d13b668a45a97230efe5f8cfa8908d48"
|
|
15766
|
+
},
|
|
15757
15767
|
{
|
|
15758
15768
|
"path": "tests/module_store_listing.mjs",
|
|
15759
15769
|
"mode": "0000644",
|
|
@@ -17524,6 +17534,11 @@
|
|
|
17524
17534
|
"mode": "0000644",
|
|
17525
17535
|
"sha256": "39e62a5fc50113e719cdaacf15e6fd054f38f8703fc64137eddee7901b374f16"
|
|
17526
17536
|
},
|
|
17537
|
+
{
|
|
17538
|
+
"path": "tests/start_goal_first.mjs",
|
|
17539
|
+
"mode": "0000644",
|
|
17540
|
+
"sha256": "a3addba1547bf0a9ce95f5d6c2ea676f63495669154e2f4c161cb8cc543b30bf"
|
|
17541
|
+
},
|
|
17527
17542
|
{
|
|
17528
17543
|
"path": "tests/start_inbox_count.mjs",
|
|
17529
17544
|
"mode": "0000644",
|
package/docs/architecture.md
CHANGED
|
@@ -291,7 +291,7 @@ Bongos views: `claimable_tasks` (rebuilt in 006 to use SELECT t.*; rebuilt again
|
|
|
291
291
|
- Own-scoped session self-service ([ADR 0206](adr/0206-own-scoped-session-revocation-no-absolute-ceiling.md), task 1003341), in [`src/bongos/routes/my-sessions.js`](../src/bongos/routes/my-sessions.js): `GET /me/sessions` (every live session on the caller's own account — source, user agent, created/last-used/expires, a `current` flag; the token value is never selected), `POST /me/sessions/revoke` (`{session_id}` — one of your own; a miss answers `revoked: 0` so a stranger's id cannot be probed) and `POST /me/sessions/revoke-all` (sign out everywhere, the requesting session included). All three gate on `requireBuilder` alone and key every statement on `req.builder.id` — an account is never named in the query, body or path. They exist because a bearer refreshes to `now()+24h` on every call, so a leaked one never expires, and the cross-builder pair (`POST /auth/revoke`, `/auth/revoke-all`, both still gated on the admin `auth.session.revoke` atom) needed an Archon the victim might not have. Surfaced in the hall at Settings → Active sessions.
|
|
292
292
|
- Authenticated CLI/web: `GET /me` (returns `rank`), `GET /versions[/progress]`, `GET /tasks[?version=&status=&kind=&discipline=]`, `GET /tasks/claimable[?version=&discipline=]`, `GET /tasks/:id`, `POST /tasks` (accepts `discipline` + `criterion_ids`; **metic+** — [ADR 0090](adr/0090-metic-task-authoring.md)), `PATCH /tasks/:id` (supports `parent_task_id`, `kind`, `discipline`, `goal_id` — (re)assign the task's goal; must be a goal in the task's own version, task 1763; **metic+**), `POST /tasks/:id/promote` (**metic+**), `POST /claims`, `POST /claims/:id/resolve`, `POST /cost`.
|
|
293
293
|
- Criterion rollup + task↔criterion links ([ADR 0025](adr/0025-structured-criterion-task-link.md), [#435](https://example.com/builders#/task/435)/[#438](https://example.com/builders#/task/438)): `GET /versions/:id/progress` rolls up each done-when criterion → its gating tasks (via `task_criteria`) → live status counts + the not-yet-shipped `remaining[]` + `unattributed_tasks` (the read behind the `/status` skill, in `src/bongos/done-when.js criterionProgress`). The link CRUD mirrors the dependency endpoints: `GET /tasks/:id/criteria`, `POST /tasks/:id/criteria` (**metic+** — [ADR 0090](adr/0090-metic-task-authoring.md)) and `DELETE /tasks/:id/criteria/:criterionId` (**metic+**). A criterion ref is a numeric `done_when_criteria.id`, a positional `"Cn"` token, or a `criterion_id` slug, resolved against the task's version (`POST /tasks` links at create time so `/status` counts the task with no backfill).
|
|
294
|
-
- Module store publish (ADR 0338 D1, task 1004271): `POST /store/modules/:key/versions` (`requireBuilder` + `requirePermission('module.submit')`, Metic floor). The body is the raw gzip tarball `bongos module publish` builds (own `express.raw` parser, 5 MB cap; 16 MB / 2000 files unpacked). `scripts/gds/module-artifact.js` re-verifies every hash and the publish gate, then `src/bongos/module-store.js` keeps the file under `var/module-store/` and inserts the `store_module_versions` row in one transaction. The first publish makes the caller the author; later versions are author-only, newer than the last and never overwritten; a delisted key takes none.
|
|
294
|
+
- Module store publish (ADR 0338 D1, task 1004271): `POST /store/modules/:key/versions` (`requireBuilder` + `requirePermission('module.submit')`, Metic floor). The body is the raw gzip tarball `bongos module publish` builds (own `express.raw` parser, 5 MB cap; 16 MB / 2000 files unpacked). `scripts/gds/module-artifact.js` re-verifies every hash and the publish gate, then `src/bongos/module-store.js` keeps the file under the INSTANCE root's `var/module-store/` (`MODULE_STORE_DIR` overrides; never inside the installed core package, which every upgrade replaces, and a boot move carries files from that old path, task 1004399) and inserts the `store_module_versions` row in one transaction. The first publish makes the caller the author; later versions are author-only, newer than the last and never overwritten; a delisted key takes none.
|
|
295
295
|
- Module store install (ADR 0338 D1, task 1003785), all `requireBuilder` and own-scoped (the holder is the caller): `POST /store/modules/:key/acquire` (`{version?}`; grants a free entitlement — no price row, or a zero price, is free — or `402 payment_required` for a priced module; refuses a delisted one), `GET /store/modules/:key/versions/:version/tarball` (served only to a live entitlement holder; `X-Tarball-Sha256`), `POST /store/modules/:key/acquired` (`{version}` → `recordAcquired`). Read side in `src/bongos/module-store.js` (`resolveAcquirable`, `isFreePrice`, `versionArtifactFile`). No channel check yet (tasks 1003815, 1003805). `bongos module update` (task 1003786) reuses the same three calls; the acquire route reads price only for a caller with no entitlement, so a holder is never charged again.
|
|
296
296
|
- Module store delist (ADR 0338 D2, task 1003797): `POST /store/modules/:key/delist` (`requireBuilder` + `requirePermission('module.delist')`, Metic floor, granted by `government_022`; strict body `{reason}`, reason required, recorded by the audit middleware). Sets `store_modules.status='delisted'` and `delisted_at` — one-way, no relist route. Every store read already refuses a delisted key (acquire, tarball, acquired, publish), while `GET /store/modules/latest` still answers with the status so `bongos upgrade` warns the holder. `GET /modules` sets `store_delisted` on an instance-tree module the store delisted (hall pill "delisted · no more updates"), and `bongos module list` marks it DELISTED (fail-soft store check, instance modules only). Never revokes an entitlement, never reaches an installed copy, never writes a score (ADR 0359). 404 unknown key, 409 `already_delisted`.
|
|
297
297
|
- Server-mediated branch publish (ADR 0055 / [#1025](https://example.com/builders#/task/1025) — lets a checkout with no GitHub push credential ship). Owner-gated like ship: `POST /tasks/:id/publish-branch` (`requireBuilder` + `gateTaskOwnership`; own 32 MB json parser for the base64 thin-bundle body, 20 MB decoded cap; the server pushes the branch + opens the PR + auto-merges with a server-side push credential — a **GitHub App** installation token (short-lived, repo-scoped; preferred, [ADR 0055](adr/0055-server-mediated-branch-publish.md) update / task 1028) or the `GITHUB_PUSH_TOKEN` PAT fallback; `503 push_unconfigured` when neither is set; `400 tip_mismatch` if the bundle tip ≠ the claimed `head_sha`) and `GET /tasks/:id/publish-status?branch=…` (polls PR + deploy-prod state derived live from GitHub). `branch` is optional: without it the server finds the task's own PR by its `task <id>: …` title, and the answer names the `branch` it used and its `branch_source` (`query` / `task_pr`, or `null` with a `branch_hint` when there is no such PR) — never a `400` (task 1003764). When the PR is still open it also carries `merge_driver`, whose `reason` says why it has not merged; on a red PR `failing_tests` names the failing unit tests (read from the `unit-report` commit status the `unit` workflow posts) and `checks_unreadable: true` says the check list is blind because the App lacks `Checks: read` — so an empty `checks[]` is never read as "nothing failed" (task 1003988, ADR 0301). Because the server's credential is the PR's AUTHOR, publish also **assigns the PR to the task's claim holder** and names them in the body (`modules/lifecycle/pr-assign.js`, task 1003991) — best-effort: the 201 carries `pr_assignee` (the login, or `null` when GitHub would not assign it) and a failed assignment never fails the publish. `ship.js` uses these in `ci` mode only when `pushVia()` resolves to server (no authenticated `gh` on the machine, or `<PREFIX>_PUSH_VIA_SERVER=1`); a machine with `gh` keeps the local push path unchanged. Implementation in `src/bongos/github-push.js`.
|
|
@@ -10,7 +10,64 @@ This document is what every build task in goal 1000121 cites. Name a screen by i
|
|
|
10
10
|
- **what the new design ADDS**;
|
|
11
11
|
- the decisions that govern both.
|
|
12
12
|
|
|
13
|
-
##
|
|
13
|
+
## One-page flow — supersedes the blended six screens (owner, 2026-10-08)
|
|
14
|
+
|
|
15
|
+
> *Summary wins, do it in the night-room look, if it's day time let's make a day view, and likewise, same for the artist page.* Then: *approved.* — the owner, 2026-10-08.
|
|
16
|
+
|
|
17
|
+
The owner found the six-screen flow still overcomplicated, and the GitHub steps after Create still failing. The mockup in [`mocks/day-night-rooms/`](mocks/day-night-rooms/README.md) is now the design of record for **every hub-side creation screen and for the artist studio's room**. Where it disagrees with the blended flow below, the mockup wins. Genesis (part 2) and the blend rule are unchanged.
|
|
18
|
+
|
|
19
|
+
### The owner's decisions
|
|
20
|
+
|
|
21
|
+
1. **One page, three parts.** Sign-in stays the gate in front. Then one page:
|
|
22
|
+
- **01 Your project:** name, kind, the optional sentence, and the four quick checks (country, anyone under 18, where builders will be, who's behind it). The screening stop still fires before anything is created.
|
|
23
|
+
- **02 How it's run:** a summary to skim. Each row (code, address, team, extras, rewards, hosting, we run, look) has a **Change** link that opens the real control in place.
|
|
24
|
+
- **03 Connect GitHub:** opens once the project exists, below **Create my project**.
|
|
25
|
+
2. **The summary wins.** No row needs confirming and Create is not gated on the rows. This **replaces** the blended flow's "the review must not be skippable" rule. The terms stay a required checkbox above Create, and the terms version stamp still records the acceptance.
|
|
26
|
+
3. **GitHub in one trip (option A).** One click: approve the project's app on GitHub, go straight on to installing it on the repo, and land back on the page. A checklist ticks itself from what the server has verified (approve the app · install it on your repo · builders can sign in · command-line sign-in is on). Coming back works from any tab or device. The founder chooses a personal or an organisation account. A failure says what stopped, and "Pick up where it stopped" resumes without making a second app.
|
|
27
|
+
4. **Day room and night room, by the viewer's own clock.** Day from 07:00 to 18:59 local time, night otherwise. The day room is the same drawing in daylight: a sunny sky with clouds, a sun for the moon, lighter towers with unlit windows, a warm wall and floor, the lamp off and a patch of sun on the floor. Its palette is the mockup's `[data-sky="day"]` tokens. **The artist studio gets the same day room**, and its greeting follows the time of day.
|
|
28
|
+
5. **The demo is a small link** under the heading, not its own screen.
|
|
29
|
+
|
|
30
|
+
### Nothing the live wizard does is lost
|
|
31
|
+
|
|
32
|
+
This table replaces the blended flow's table below for every hub-side capability. The mockup draws the simple path; each row's capability is a reveal or sits behind a summary row's **Change**.
|
|
33
|
+
|
|
34
|
+
| Today | In the one-page flow |
|
|
35
|
+
|---|---|
|
|
36
|
+
| Q1 new or existing, and what already exists | Part 02's Code row: "A repo I already have" reveals what already exists and the adopt path. |
|
|
37
|
+
| Adopt: GitHub repo picker, manual fallback, conflict pre-flight, backlog import | Behind the Code row's Change, unchanged. |
|
|
38
|
+
| Q2 geography | Part 01's "Where are you?", with state or province revealed beside the country for the countries that need it, and "Where will builders be?". |
|
|
39
|
+
| Q3 under-18s and the age tiers | Part 01; "Yes" reveals the tiers and the guardian rules in place. |
|
|
40
|
+
| Q4 entity, new intent or existing legal type | Part 01's "Who's behind it?"; a company or nonprofit reveals the full entity list. |
|
|
41
|
+
| The screening stop | Unchanged: an answer the platform cannot serve stops the page before Create, with the reason in place. |
|
|
42
|
+
| Q5 one or many, count, growth guess | Part 02's Team row; Change opens the count, the growth guess and how people join. |
|
|
43
|
+
| Q6 rewards | Part 02's Rewards row (pick several). |
|
|
44
|
+
| Q7 framing A–D and the hybrid suite | Part 02's **We run** row ("Everything, for you" by default); "Some of it" reveals the service checklist. |
|
|
45
|
+
| Auto-fill from my account | Kept, as an offer at the top of part 01 when the account has a last project. |
|
|
46
|
+
| Resume a saved draft | Kept, as an offer at the top of the page (there is no fork screen any more). |
|
|
47
|
+
| Type and About | Part 01's Kind and "In a sentence". |
|
|
48
|
+
| Modules picker and search | Part 02's Extras row; Change opens the searchable picker. |
|
|
49
|
+
| Address: handle, own domain, no address | Part 02's Address row (PS19: shown on every path, the demo included). |
|
|
50
|
+
| App: Render key check, workspace, runtime, commands, plan, database | Part 02's Hosting row; Render opens today's settings in place. |
|
|
51
|
+
| Look, logo, night tint | Part 02's Look row; Change opens the look picker, the logo and the night tint. |
|
|
52
|
+
| Terms acknowledgement | The required checkbox above Create; the terms version stamp still records it. |
|
|
53
|
+
| Review | Part 02 itself, plus the one-line recap beside Create. Nothing is gated on confirming rows. |
|
|
54
|
+
| Done: standup strip, the owner-only steps, invite | Part 03 on the same page: the GitHub checklist replaces the paged owner-only steps, then "is alive" with the invite box (once the hall is live, PS15) and the genesis door. |
|
|
55
|
+
| Demo: fit check, settings, teammates, 30-day notice | The "Try a small demo instead" link opens the demo's people, hours, idea and teammates in place at the top of part 01, marks the page "demo", and shows the fit sentence; the rest of the page is unchanged. |
|
|
56
|
+
|
|
57
|
+
### Who builds it
|
|
58
|
+
|
|
59
|
+
| Task | Builds |
|
|
60
|
+
|---|---|
|
|
61
|
+
| task 1004647 | This record and the mockup. |
|
|
62
|
+
| task 1004648 | The one page, the day room on it, and removing the old panels. |
|
|
63
|
+
| task 1004649 | GitHub in one trip, with the self-ticking checklist. |
|
|
64
|
+
| task 1004650 | The hand-back from any tab or device, and checking while the page is hidden. |
|
|
65
|
+
| task 1004651 | A personal or organisation account for the app. |
|
|
66
|
+
| task 1004652 | Resuming a stopped step, and a fixing button for every failure. |
|
|
67
|
+
| task 1004653 | The end-to-end walk, day and night, desktop and phone. |
|
|
68
|
+
| task 1004654 | The artist studio's day room and time-of-day greeting. |
|
|
69
|
+
|
|
70
|
+
## The blended flow — superseded by the one-page flow above (owner, 2026-10-01)
|
|
14
71
|
|
|
15
72
|
> *Go for this as the design of record.* — the owner, approving the blended mockup (task 1004515).
|
|
16
73
|
|
|
@@ -3002,5 +3002,9 @@ is load-bearing: the script throws rather than guess if it is missing, and
|
|
|
3002
3002
|
landed since 1.21.83 with no explicit bump. run 37876887587. (task 1002620)
|
|
3003
3003
|
1.21.85 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
3004
3004
|
landed since 1.21.84 with no explicit bump. run 37884200093. (task 1002620)
|
|
3005
|
+
1.21.86 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
3006
|
+
landed since 1.21.85 with no explicit bump. run 37885516955. (task 1002620)
|
|
3007
|
+
1.21.87 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
3008
|
+
landed since 1.21.86 with no explicit bump. run 37886940909. (task 1002620)
|
|
3005
3009
|
---------------------------------------------------------------------------
|
|
3006
3010
|
```
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.21.
|
|
3
|
+
"version": "1.21.87",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.21.
|
|
9
|
+
"version": "1.21.87",
|
|
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.21.
|
|
3
|
+
"version": "1.21.87",
|
|
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/release-notes.json
CHANGED
|
@@ -9336,5 +9336,21 @@
|
|
|
9336
9336
|
"id": "1004379",
|
|
9337
9337
|
"text": "A page tweak that passed its grade and is waiting for its artist no longer reports itself as a failed grade: the closing card, the PR description and the credits line now all say it passed and is waiting for the artist's appro"
|
|
9338
9338
|
}
|
|
9339
|
+
],
|
|
9340
|
+
"1.21.86": [
|
|
9341
|
+
{
|
|
9342
|
+
"id": "1004399",
|
|
9343
|
+
"text": "Published module packages are now kept in the project's own data folder instead of inside the installed core, so a core upgrade no longer deletes them; any already stored in the old place are moved over when the server starts."
|
|
9344
|
+
}
|
|
9345
|
+
],
|
|
9346
|
+
"1.21.87": [
|
|
9347
|
+
{
|
|
9348
|
+
"id": "1004647",
|
|
9349
|
+
"text": "The new project creation design is now on record for every builder: one page in three parts instead of seven screens, a skimmable summary instead of confirming every row, GitHub in a single trip with a checklist that ticks its"
|
|
9350
|
+
},
|
|
9351
|
+
{
|
|
9352
|
+
"id": "1002852",
|
|
9353
|
+
"text": "The task list a builder sees at the start of a session now opens with the goals they have joined and how much work in each is ready, and groups the tasks under their goals, with the builder's own goals first and anything witho"
|
|
9354
|
+
}
|
|
9339
9355
|
]
|
|
9340
9356
|
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// scripts/gds/start-goals.js — /builder-start, goal-first (task 1002852).
|
|
2
|
+
//
|
|
3
|
+
// WHY. Work is organised in goals, but /builder-start opened on a flat list of
|
|
4
|
+
// tasks, so a builder saw "task 1002852" and never which goal it served or which of
|
|
5
|
+
// their own goals had work waiting. This leads with the goals the builder has
|
|
6
|
+
// JOINED, then shows the same claimable table grouped under each task's goal.
|
|
7
|
+
//
|
|
8
|
+
// The owner's two rules (2026-10-08, WA6 chain kickoff):
|
|
9
|
+
// - the goals sit ABOVE the claimable table and never replace it, so nothing
|
|
10
|
+
// claimable is hidden from a builder who has joined no goal;
|
|
11
|
+
// - a task with no goal is listed under "Other work". Nothing is backfilled.
|
|
12
|
+
//
|
|
13
|
+
// PURE: no I/O. start.js fetches GET /goals (best-effort: a failed read groups by
|
|
14
|
+
// goal id with a generic name and lists no joined goals) and hands both lists in.
|
|
15
|
+
|
|
16
|
+
'use strict';
|
|
17
|
+
|
|
18
|
+
const OTHER_WORK = 'Other work';
|
|
19
|
+
const JOINED_SHOWN = 5;
|
|
20
|
+
|
|
21
|
+
// The open goals this builder is a member of, in the board's own order (version,
|
|
22
|
+
// sort order). `viewer_is_member` is the server's answer, never re-derived here.
|
|
23
|
+
function joinedGoals(goals) {
|
|
24
|
+
return (Array.isArray(goals) ? goals : [])
|
|
25
|
+
.filter((g) => g && g.viewer_is_member === true && g.status === 'open');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function goalName(id, byId) {
|
|
29
|
+
const g = byId.get(String(id));
|
|
30
|
+
return (g && g.title) || `Goal ${id}`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Group claimable rows by goal, keeping each row's place in the server's order
|
|
34
|
+
// (priority, then estimate) inside its group. Groups run: the builder's joined
|
|
35
|
+
// goals first (board order), then every other goal by its first row, then
|
|
36
|
+
// "Other work" last. Every row lands in exactly one group, so the grouped table
|
|
37
|
+
// lists precisely the rows the flat one did.
|
|
38
|
+
function groupByGoal(rows, goals) {
|
|
39
|
+
const list = Array.isArray(rows) ? rows : [];
|
|
40
|
+
const byId = new Map((Array.isArray(goals) ? goals : []).map((g) => [String(g.id), g]));
|
|
41
|
+
const joinedOrder = joinedGoals(goals).map((g) => String(g.id));
|
|
42
|
+
const groups = new Map();
|
|
43
|
+
for (const t of list) {
|
|
44
|
+
const key = t.goal_id == null ? null : String(t.goal_id);
|
|
45
|
+
if (!groups.has(key)) {
|
|
46
|
+
groups.set(key, {
|
|
47
|
+
goal_id: key,
|
|
48
|
+
title: key == null ? OTHER_WORK : goalName(key, byId),
|
|
49
|
+
joined: key != null && joinedOrder.includes(key),
|
|
50
|
+
rows: [],
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
groups.get(key).rows.push(t);
|
|
54
|
+
}
|
|
55
|
+
const all = [...groups.values()];
|
|
56
|
+
const joined = joinedOrder.map((id) => groups.get(id)).filter(Boolean);
|
|
57
|
+
const others = all.filter((g) => g.goal_id != null && !g.joined);
|
|
58
|
+
const other = groups.get(null);
|
|
59
|
+
return [...joined, ...others, ...(other ? [other] : [])];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// The "Your goals" lines above the table: each joined goal and how many tasks in
|
|
63
|
+
// it are claimable for this builder right now (from the FULL filtered set, not
|
|
64
|
+
// the capped card rows, so a count never under-reports). Empty when the builder
|
|
65
|
+
// has joined no open goal; the table below still lists everything.
|
|
66
|
+
function yourGoals(goals, claimable) {
|
|
67
|
+
const counts = new Map();
|
|
68
|
+
for (const t of Array.isArray(claimable) ? claimable : []) {
|
|
69
|
+
if (t.goal_id == null) continue;
|
|
70
|
+
const k = String(t.goal_id);
|
|
71
|
+
counts.set(k, (counts.get(k) || 0) + 1);
|
|
72
|
+
}
|
|
73
|
+
const joined = joinedGoals(goals);
|
|
74
|
+
return {
|
|
75
|
+
goals: joined.slice(0, JOINED_SHOWN).map((g) => ({
|
|
76
|
+
id: String(g.id),
|
|
77
|
+
title: g.title || `Goal ${g.id}`,
|
|
78
|
+
claimable: counts.get(String(g.id)) || 0,
|
|
79
|
+
})),
|
|
80
|
+
more: Math.max(0, joined.length - JOINED_SHOWN),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Goal-first ordering for the capped card: rows in a goal the builder joined come
|
|
85
|
+
// first, everything else after, each half keeping the server's order. Without it
|
|
86
|
+
// the card's top rows were the board's top rows and a builder's own goals could
|
|
87
|
+
// show "5 to claim" above a table holding none of them. A stable partition, so it
|
|
88
|
+
// only moves rows, never drops one.
|
|
89
|
+
function joinedFirst(rows, goals) {
|
|
90
|
+
const list = Array.isArray(rows) ? rows : [];
|
|
91
|
+
const joined = new Set(joinedGoals(goals).map((g) => String(g.id)));
|
|
92
|
+
const mine = [];
|
|
93
|
+
const rest = [];
|
|
94
|
+
for (const t of list) (t.goal_id != null && joined.has(String(t.goal_id)) ? mine : rest).push(t);
|
|
95
|
+
return mine.concat(rest);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function claimableWord(n) {
|
|
99
|
+
return n === 0 ? 'nothing to claim right now' : `${n} to claim`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
module.exports = { OTHER_WORK, joinedGoals, joinedFirst, groupByGoal, yourGoals, claimableWord };
|
package/scripts/gds/start.js
CHANGED
|
@@ -233,23 +233,30 @@ function whatItIs(t) {
|
|
|
233
233
|
// Ids are emitted as hall links (renders as a clean #N) — a bare "#NNN" in a
|
|
234
234
|
// relayed chat message autolinks to GitHub and 404s (CLAUDE.md ref rule).
|
|
235
235
|
const surface = require('./surface');
|
|
236
|
+
const startGoals = require('./start-goals');
|
|
236
237
|
const HALL_TASK = `${hallBase()}#/task/`;
|
|
237
|
-
function claimableTable(rows, rewardFromServer) {
|
|
238
|
+
function claimableTable(rows, rewardFromServer, goals) {
|
|
238
239
|
// On a cost-plus-only instance the Reward column is an advertised estimate that
|
|
239
240
|
// is never paid — say so in the header so the column can't read as a promise,
|
|
240
241
|
// and footnote what IS paid below the table (task 1002469).
|
|
241
242
|
const perTask = rewardPolicy(rewardFromServer).perTaskCredit;
|
|
242
243
|
const rewardHead = perTask ? 'Reward' : 'Reward (est.)';
|
|
243
244
|
const headers = ['#', 'Version', ROLE_AXIS_LABEL, rewardHead, 'Est.', 'What it is'];
|
|
244
|
-
const
|
|
245
|
+
const rowCells = (t) => [
|
|
245
246
|
surface.taskRef(t.id, HALL_TASK),
|
|
246
247
|
cell(t.version_id || '?'),
|
|
247
248
|
cell(roleLabel(t) || '—'),
|
|
248
249
|
`${t.credits_reward ?? 0}c`,
|
|
249
250
|
estimateShort(t),
|
|
250
251
|
cell(whatItIs(t)),
|
|
251
|
-
]
|
|
252
|
-
|
|
252
|
+
];
|
|
253
|
+
// task 1002852: one table per goal, each under its goal's name; goal-less work
|
|
254
|
+
// last under "Other work". An empty goal list (a failed read) still groups by id.
|
|
255
|
+
const out = [];
|
|
256
|
+
for (const g of startGoals.groupByGoal(rows, goals)) {
|
|
257
|
+
if (out.length) out.push('');
|
|
258
|
+
out.push(surface.strong(g.title) + (g.joined ? ' · your goal' : ''), surface.table(headers, g.rows.map(rowCells)));
|
|
259
|
+
}
|
|
253
260
|
const cpNote = costPlusOnlyNote(rewardFromServer);
|
|
254
261
|
if (cpNote) out.push('', surface.em(cpNote));
|
|
255
262
|
return out.join('\n');
|
|
@@ -374,6 +381,7 @@ const WIDGET_STYLE = `<style>
|
|
|
374
381
|
.bs-thead{text-align:left;color:var(--color-text-tertiary);font-size:12px;}
|
|
375
382
|
.bs-th{padding:0 6px 4px;font-weight:400;}
|
|
376
383
|
.bs-tr{cursor:pointer;}
|
|
384
|
+
.bs-group td{padding:10px 0 4px;font-size:12px;font-weight:500;color:var(--color-text-secondary);}
|
|
377
385
|
.bs-td{padding:7px 6px;border-top:0.5px solid var(--color-border-tertiary);}
|
|
378
386
|
.bs-id{color:var(--color-text-secondary);font-family:var(--font-mono);font-size:12px;}
|
|
379
387
|
.bs-nowrap{white-space:nowrap;}
|
|
@@ -384,7 +392,7 @@ const WIDGET_STYLE = `<style>
|
|
|
384
392
|
.bs-foot{font-size:13px;color:var(--color-text-tertiary);margin-top:12px;}
|
|
385
393
|
</style>`;
|
|
386
394
|
|
|
387
|
-
function renderWidget({ builder, claims, claimable, total, streak, recommendations, header, routingByPreference, preferred, filters, readyToClose, needsRebaseTasks }) {
|
|
395
|
+
function renderWidget({ builder, claims, claimable, total, streak, recommendations, header, routingByPreference, preferred, filters, readyToClose, needsRebaseTasks, goals, yourGoals }) {
|
|
388
396
|
const b = builder || {};
|
|
389
397
|
// ADR 0120 part 5: the owed-rebase gate warning, rendered at the top of the
|
|
390
398
|
// card (before status/holdings) since it blocks new claims. '' when none owed.
|
|
@@ -443,7 +451,7 @@ function renderWidget({ builder, claims, claimable, total, streak, recommendatio
|
|
|
443
451
|
|
|
444
452
|
// rows — click to claim. Columns: # / Version / Type / Reward /
|
|
445
453
|
// Est. / What it is (the work lane is its own column, not baked into the title).
|
|
446
|
-
const
|
|
454
|
+
const taskRow = (t) => {
|
|
447
455
|
const claimMsg = jsq(`Claim task ${t.id}`);
|
|
448
456
|
const titleAttr = hesc(`Claim #${t.id}: ${t.title || ''}`);
|
|
449
457
|
const d = t.discipline;
|
|
@@ -458,7 +466,16 @@ function renderWidget({ builder, claims, claimable, total, streak, recommendatio
|
|
|
458
466
|
+ TD(hesc(estimateShort(t)), 'bs-sub')
|
|
459
467
|
+ TD(hesc(t.title || ''), 'bs-ellip')
|
|
460
468
|
+ `</tr>`;
|
|
461
|
-
}
|
|
469
|
+
};
|
|
470
|
+
// task 1002852: the same rows, grouped under each task's goal ("Other work" last).
|
|
471
|
+
const rows = startGoals.groupByGoal(claimable, goals).map((g) =>
|
|
472
|
+
`<tr class="bs-group"><td colspan="6">${hesc(g.title)}${g.joined ? '<span class="bs-sub"> · your goal</span>' : ''}</td></tr>`
|
|
473
|
+
+ g.rows.map(taskRow).join('')).join('');
|
|
474
|
+
// task 1002852: the goals this builder joined, ABOVE the table (never instead of it).
|
|
475
|
+
const goalsBlock = yourGoals.goals.length
|
|
476
|
+
? `<div class="bs-menuh">Your goals</div>${yourGoals.goals.map((g) =>
|
|
477
|
+
`<div class="bs-line"><i class="ti ti-target" aria-hidden="true"></i>${hesc(clip(g.title, 60))}<span class="bs-dot"> · </span><span class="bs-sub">${hesc(startGoals.claimableWord(g.claimable))}</span></div>`).join('')}${yourGoals.more ? `<div class="bs-line bs-sub">and ${yourGoals.more} more</div>` : ''}`
|
|
478
|
+
: '';
|
|
462
479
|
|
|
463
480
|
const menu = claimable.length
|
|
464
481
|
? `<table class="bs-table">
|
|
@@ -495,6 +512,7 @@ function renderWidget({ builder, claims, claimable, total, streak, recommendatio
|
|
|
495
512
|
${statusRow}
|
|
496
513
|
${holdings}
|
|
497
514
|
${closeBanner}
|
|
515
|
+
${goalsBlock}
|
|
498
516
|
<div class="bs-menuh">Claim one to start — ${total || claimable.length} ready for you right now${matched}${fNote}</div>
|
|
499
517
|
${menu}
|
|
500
518
|
<div class="bs-foot">Click a task to claim it${moreLink}${crossLane}</div>
|
|
@@ -679,9 +697,9 @@ async function main() {
|
|
|
679
697
|
// endpoint fails, that widget degrades to "N/A" rather than killing
|
|
680
698
|
// /builder-start.
|
|
681
699
|
const api = await cliClient();
|
|
682
|
-
let me, r, closeRes, progressRes, costRes, recentRes, inboxRes, blockersRes;
|
|
700
|
+
let me, r, closeRes, progressRes, costRes, recentRes, inboxRes, blockersRes, goalsRes;
|
|
683
701
|
try {
|
|
684
|
-
[me, r, closeRes, progressRes, costRes, recentRes, inboxRes, blockersRes] = await Promise.all([
|
|
702
|
+
[me, r, closeRes, progressRes, costRes, recentRes, inboxRes, blockersRes, goalsRes] = await Promise.all([
|
|
685
703
|
api.me.getMe(),
|
|
686
704
|
api.tasks.getTasksClaimable({ query: claimableQuery }),
|
|
687
705
|
api.versions.getVersionsCloseSuggestions(),
|
|
@@ -690,6 +708,8 @@ async function main() {
|
|
|
690
708
|
api.public.getPublicRecentShipped(),
|
|
691
709
|
api.inbox.getInbox({ query: { status: 'open' } }),
|
|
692
710
|
api.blockers.getBlockers(),
|
|
711
|
+
// task 1002852: best-effort — a failed goals read leaves the table flat-grouped by id.
|
|
712
|
+
api.goals.getGoals({ query: { status: 'open' } }).catch(() => ({ ok: false })),
|
|
693
713
|
]);
|
|
694
714
|
} catch (err) {
|
|
695
715
|
// A fresh-clone instance that was never brought up refuses the connection.
|
|
@@ -741,7 +761,10 @@ async function main() {
|
|
|
741
761
|
const filtered = routingByPreference
|
|
742
762
|
? rawClaimable.filter((t) => preferred.includes(t.discipline))
|
|
743
763
|
: rawClaimable;
|
|
744
|
-
|
|
764
|
+
// task 1002852: goal-first. The open goals (with the server's viewer_is_member)
|
|
765
|
+
// group the table and put the builder's joined goals' rows ahead of the cap.
|
|
766
|
+
const goals = goalsRes && goalsRes.ok && Array.isArray(goalsRes.data?.goals) ? goalsRes.data.goals : [];
|
|
767
|
+
const claimable = startGoals.joinedFirst(filtered, goals).slice(0, limit);
|
|
745
768
|
|
|
746
769
|
// "You might enjoy" — up to 3 claimable tasks outside the builder's lanes.
|
|
747
770
|
// Computed from the FULL set so it can reach across disciplines. Suppressed
|
|
@@ -758,6 +781,7 @@ async function main() {
|
|
|
758
781
|
? closeRes.data.suggestions
|
|
759
782
|
: [];
|
|
760
783
|
const readyToClose = closeSuggestions.filter((s) => s.ready);
|
|
784
|
+
const yourGoals = startGoals.yourGoals(goals, filtered);
|
|
761
785
|
|
|
762
786
|
// Header widget data (each independently optional).
|
|
763
787
|
const progress = progressRes.ok && Array.isArray(progressRes.data?.progress) ? progressRes.data.progress : null;
|
|
@@ -781,6 +805,7 @@ async function main() {
|
|
|
781
805
|
me: me.data,
|
|
782
806
|
claimable,
|
|
783
807
|
recommendations,
|
|
808
|
+
your_goals: yourGoals.goals,
|
|
784
809
|
discipline_routing: {
|
|
785
810
|
preferred_disciplines: preferred,
|
|
786
811
|
applied: routingByPreference,
|
|
@@ -836,6 +861,8 @@ async function main() {
|
|
|
836
861
|
filters: { version, discipline, fit },
|
|
837
862
|
readyToClose,
|
|
838
863
|
needsRebaseTasks,
|
|
864
|
+
goals,
|
|
865
|
+
yourGoals,
|
|
839
866
|
}) + '\n');
|
|
840
867
|
return;
|
|
841
868
|
}
|
|
@@ -951,11 +978,18 @@ async function main() {
|
|
|
951
978
|
fit && `fit=${fit}`,
|
|
952
979
|
].filter(Boolean);
|
|
953
980
|
const filterNote = filterBits.length ? ` (${filterBits.join(', ')})` : '';
|
|
981
|
+
// task 1002852: the goals this builder joined, above the table (never instead of it).
|
|
982
|
+
if (yourGoals.goals.length) {
|
|
983
|
+
console.log(surface.strong('Your goals'));
|
|
984
|
+
for (const g of yourGoals.goals) console.log(` ${clip(g.title, 60)} · ${startGoals.claimableWord(g.claimable)}`);
|
|
985
|
+
if (yourGoals.more) console.log(` and ${yourGoals.more} more`);
|
|
986
|
+
console.log('');
|
|
987
|
+
}
|
|
954
988
|
console.log(`${surface.strong('Claim one to start')} — ${filtered.length} ready for you right now${matched}${filterNote}:`);
|
|
955
989
|
console.log('');
|
|
956
990
|
if (claimable.length) {
|
|
957
991
|
const cardRows = claimable.slice(0, CARD_ROWS);
|
|
958
|
-
console.log(claimableTable(cardRows, me.ok ? me.data?.reward : null));
|
|
992
|
+
console.log(claimableTable(cardRows, me.ok ? me.data?.reward : null, goals));
|
|
959
993
|
const moreN = filtered.length - cardRows.length;
|
|
960
994
|
if (moreN > 0) {
|
|
961
995
|
console.log('');
|
|
@@ -1012,6 +1046,8 @@ async function main() {
|
|
|
1012
1046
|
filters: { version, discipline, fit },
|
|
1013
1047
|
readyToClose,
|
|
1014
1048
|
needsRebaseTasks,
|
|
1049
|
+
goals,
|
|
1050
|
+
yourGoals,
|
|
1015
1051
|
})
|
|
1016
1052
|
: null;
|
|
1017
1053
|
const markdown = captureLog(emitMarkdownCard).join('\n');
|
|
@@ -8,9 +8,8 @@
|
|
|
8
8
|
// 1. stageArtifact — write the tarball under the store dir (a temp name first;
|
|
9
9
|
// commitArtifact moves it into place inside step 2's transaction). ADR 0338 lets the
|
|
10
10
|
// store keep tarballs on the control plane's disk while volume is small, so it
|
|
11
|
-
// does: <MODULE_STORE_DIR or var/module-store>/<key>/<key>-<version>.tgz
|
|
12
|
-
//
|
|
13
|
-
// wipes it.
|
|
11
|
+
// does: <MODULE_STORE_DIR or <instance root>/var/module-store>/<key>/<key>-<version>.tgz
|
|
12
|
+
// (see storeDirFor for why the INSTANCE root, task 1004399).
|
|
14
13
|
// 2. publishVersion — one transaction over the registry tables (core_257): create
|
|
15
14
|
// the store_modules row on a key's first publish (the publisher becomes its
|
|
16
15
|
// author), refuse anyone else's key, refuse a delisted key (ADR 0338 D2: delisting
|
|
@@ -26,9 +25,105 @@ const crypto = require('node:crypto');
|
|
|
26
25
|
const { parseVersion, cmp } = require('../module-loader/semver');
|
|
27
26
|
const { KEY_RE } = require('../module-loader/manifest-schema');
|
|
28
27
|
|
|
28
|
+
// Where the store lives: <INSTANCE ROOT>/var/module-store (task 1004399).
|
|
29
|
+
//
|
|
30
|
+
// THE BUG THIS FIXES. This used to be path.join(__dirname, '..', '..', 'var',
|
|
31
|
+
// 'module-store') — the CORE root. Right in a single checkout, where core root and
|
|
32
|
+
// instance root are one directory; wrong in the consumer layout, where __dirname is
|
|
33
|
+
// node_modules/@bongos/core/src/bongos, so every published tarball was written INSIDE
|
|
34
|
+
// the installed package and every core upgrade (npm ci replaces it) erased them all,
|
|
35
|
+
// leaving registry rows pointing at nothing. Task 1004381 found and fixed the same
|
|
36
|
+
// failure for task visuals (modules/lifecycle/task-visuals.js); this follows it.
|
|
37
|
+
//
|
|
38
|
+
// MODULE_STORE_DIR stays the override (tests, or an operator who wants the tarballs on
|
|
39
|
+
// another disk). The default store carries a `*` .gitignore, because the instance root
|
|
40
|
+
// is a git checkout whose tree `bongos upgrade` requires to be clean.
|
|
41
|
+
let instanceRootCache;
|
|
42
|
+
function instanceRoot() {
|
|
43
|
+
if (instanceRootCache === undefined) {
|
|
44
|
+
try {
|
|
45
|
+
instanceRootCache = require('../instance-config').resolveInstanceRoot();
|
|
46
|
+
} catch (e) {
|
|
47
|
+
// A storage-path lookup must never take the server down: fall back to the
|
|
48
|
+
// historical location, which is right in a single checkout.
|
|
49
|
+
instanceRootCache = path.join(__dirname, '..', '..');
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return instanceRootCache;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// PURE given its inputs, so the consumer-layout test can prove where it lands.
|
|
56
|
+
function storeDirFor({ env = process.env, root } = {}) {
|
|
57
|
+
if (env.MODULE_STORE_DIR) return env.MODULE_STORE_DIR;
|
|
58
|
+
return path.join(root || instanceRoot(), 'var', 'module-store');
|
|
59
|
+
}
|
|
29
60
|
|
|
30
61
|
function storeDir() {
|
|
31
|
-
return
|
|
62
|
+
return storeDirFor();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// The old, in-package location. Read only by migrateLegacyStore.
|
|
66
|
+
function legacyStoreDir() {
|
|
67
|
+
return path.join(__dirname, '..', '..', 'var', 'module-store');
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// mkdir -p the store and, for the DEFAULT store, drop a `*` .gitignore in it so the
|
|
71
|
+
// tarballs never show as untracked in the instance's git tree (an untracked path
|
|
72
|
+
// there halts the next `bongos upgrade` pre-flight). An override is the operator's
|
|
73
|
+
// own directory and is left bare. Idempotent; the .gitignore write is best-effort.
|
|
74
|
+
const DIR_GITIGNORE = '# Module store tarballs (task 1004399): runtime data, never committed.\n*\n';
|
|
75
|
+
async function ensureStoreDir(dir, { env = process.env } = {}) {
|
|
76
|
+
await fs.promises.mkdir(dir, { recursive: true });
|
|
77
|
+
if (env.MODULE_STORE_DIR || path.resolve(dir) !== path.resolve(storeDirFor({ env }))) return;
|
|
78
|
+
try {
|
|
79
|
+
await fs.promises.writeFile(path.join(dir, '.gitignore'), DIR_GITIGNORE, { flag: 'wx' });
|
|
80
|
+
} catch { /* EEXIST is the steady state; anything else is non-fatal */ }
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// One-time boot move: every tarball still at the old in-package path moves into the
|
|
84
|
+
// instance store, keeping its <key>/<key>-<version>.tgz place — the registry stores
|
|
85
|
+
// artifact_path RELATIVE to the store dir, so the rows stay right with no data
|
|
86
|
+
// migration. Only well-formed artifacts move (a key directory matching KEY_RE holding
|
|
87
|
+
// "<key>-<version>.tgz"); a leftover .partial is not one. A no-op when both paths are
|
|
88
|
+
// the same directory (a single checkout), the old one is absent, or MODULE_STORE_DIR
|
|
89
|
+
// is set (the old code honoured it too, so nothing was ever written to the legacy
|
|
90
|
+
// path). Never overwrites, never throws: returns { moved, skipped, failed }.
|
|
91
|
+
async function migrateLegacyStore({ from, to = storeDir(), env = process.env } = {}) {
|
|
92
|
+
const out = { moved: 0, skipped: 0, failed: 0 };
|
|
93
|
+
if (from === undefined) {
|
|
94
|
+
if (env.MODULE_STORE_DIR) return out;
|
|
95
|
+
from = legacyStoreDir();
|
|
96
|
+
}
|
|
97
|
+
if (path.resolve(from) === path.resolve(to)) return out;
|
|
98
|
+
let keys;
|
|
99
|
+
try { keys = await fs.promises.readdir(from); } catch { return out; }
|
|
100
|
+
for (const key of keys.filter((k) => KEY_RE.test(k))) {
|
|
101
|
+
let names;
|
|
102
|
+
try { names = await fs.promises.readdir(path.join(from, key)); } catch { continue; }
|
|
103
|
+
for (const name of names) {
|
|
104
|
+
const m = name.startsWith(`${key}-`) && name.endsWith('.tgz') ? name.slice(key.length + 1, -4) : null;
|
|
105
|
+
if (!m || !parseVersion(m)) continue;
|
|
106
|
+
const src = path.join(from, key, name);
|
|
107
|
+
const dest = artifactPath(key, m, { dir: to });
|
|
108
|
+
try {
|
|
109
|
+
try { await fs.promises.access(dest); out.skipped += 1; continue; } catch { /* absent: move it */ }
|
|
110
|
+
await ensureStoreDir(to, { env });
|
|
111
|
+
await fs.promises.mkdir(path.dirname(dest), { recursive: true });
|
|
112
|
+
try {
|
|
113
|
+
await fs.promises.rename(src, dest);
|
|
114
|
+
} catch (err) {
|
|
115
|
+
if (!err || err.code !== 'EXDEV') throw err;
|
|
116
|
+
// Another filesystem: copy, then remove the original.
|
|
117
|
+
await fs.promises.copyFile(src, dest, fs.constants.COPYFILE_EXCL);
|
|
118
|
+
await fs.promises.unlink(src);
|
|
119
|
+
}
|
|
120
|
+
out.moved += 1;
|
|
121
|
+
} catch {
|
|
122
|
+
out.failed += 1;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return out;
|
|
32
127
|
}
|
|
33
128
|
|
|
34
129
|
// Where one version's tarball lives. Key and version are re-validated here so this
|
|
@@ -48,6 +143,7 @@ function artifactPath(key, version, { dir = storeDir() } = {}) {
|
|
|
48
143
|
// Async throughout so a multi-MB write never holds the web process's event loop.
|
|
49
144
|
async function stageArtifact(buf, { key, version, dir = storeDir() } = {}) {
|
|
50
145
|
const final = artifactPath(key, version, { dir });
|
|
146
|
+
await ensureStoreDir(dir);
|
|
51
147
|
await fs.promises.mkdir(path.dirname(final), { recursive: true });
|
|
52
148
|
const tmp = `${final}.${process.pid}.${Date.now()}.${crypto.randomBytes(4).toString('hex')}.partial`;
|
|
53
149
|
await fs.promises.writeFile(tmp, buf, { flag: 'wx', mode: 0o640 });
|
|
@@ -276,5 +372,5 @@ function versionArtifactFile(row, { dir = storeDir() } = {}) {
|
|
|
276
372
|
module.exports = {
|
|
277
373
|
stageArtifact, commitArtifact, removeArtifact, relativeArtifactPath, publishVersion,
|
|
278
374
|
isFreePrice, resolveAcquirable, resolveReadable, publishedVersionSet, latestVersions, versionArtifactFile,
|
|
279
|
-
delistModule, delistedKeySet,
|
|
375
|
+
delistModule, delistedKeySet, storeDir, storeDirFor, migrateLegacyStore,
|
|
280
376
|
};
|
|
@@ -95,7 +95,7 @@ const { checkEntitlement, listEntitlements, grantEntitlement, recordAcquired } =
|
|
|
95
95
|
const {
|
|
96
96
|
stageArtifact, commitArtifact, removeArtifact, relativeArtifactPath, publishVersion,
|
|
97
97
|
resolveAcquirable, resolveReadable, publishedVersionSet, latestVersions, versionArtifactFile,
|
|
98
|
-
delistModule, delistedKeySet,
|
|
98
|
+
delistModule, delistedKeySet, migrateLegacyStore,
|
|
99
99
|
} = require('../module-store');
|
|
100
100
|
const {
|
|
101
101
|
isOptedOut, setReporting, installReportError, errorCountError, recordInstallReport, recordErrorCount,
|
|
@@ -210,6 +210,12 @@ function contributeModuleDesk() {
|
|
|
210
210
|
module.exports = function buildModulesRouter() {
|
|
211
211
|
contributeModuleRechecksToDocket();
|
|
212
212
|
contributeModuleDesk();
|
|
213
|
+
// Task 1004399: carry tarballs written inside the installed core package (the old
|
|
214
|
+
// store path) into the instance store before the next upgrade erases them. Async and
|
|
215
|
+
// never fatal; a no-op in a single checkout, where the two paths are one directory.
|
|
216
|
+
migrateLegacyStore()
|
|
217
|
+
.then((r) => { if (r.moved || r.failed) log.info(r, 'module store: moved from the old in-package store'); })
|
|
218
|
+
.catch((err) => log.error({ err: err && err.message }, 'module store: boot move failed'));
|
|
213
219
|
const router = express.Router();
|
|
214
220
|
// Checked how-tos by "key@version:tarball_sha256". Per router, so a test's fresh
|
|
215
221
|
// router starts empty.
|
package/src/module-api.js
CHANGED
|
@@ -75,7 +75,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
|
|
|
75
75
|
// MAJOR (see allowBoxScope below): passes the request through untouched.
|
|
76
76
|
function deprecatedNoopMiddleware(_req, _res, next) { next(); }
|
|
77
77
|
|
|
78
|
-
const CORE_VERSION = '1.21.
|
|
78
|
+
const CORE_VERSION = '1.21.87'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
79
79
|
|
|
80
80
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
81
81
|
// Usage: const log = api.logger('discord'); log.info('mounted');
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
// tests/module_store_instance_root.mjs
|
|
2
|
+
//
|
|
3
|
+
// Task 1004399: published module tarballs must be stored under the INSTANCE root,
|
|
4
|
+
// never inside the installed core package, so they survive a core upgrade. The
|
|
5
|
+
// sibling of tests/task_visuals_instance_root.mjs (task 1004381), which found the
|
|
6
|
+
// same bug in the task-visuals store.
|
|
7
|
+
//
|
|
8
|
+
// This builds a real consumer tree in a temp dir: a fake installed core package
|
|
9
|
+
// carrying the REAL src/bongos/module-store.js, with its three core requires
|
|
10
|
+
// re-exporting the real files, then proves, through that installed copy:
|
|
11
|
+
// 1. the store is the instance's var/module-store, not inside the package;
|
|
12
|
+
// 2. a published tarball survives the package being deleted and reinstalled,
|
|
13
|
+
// and the registry row's artifact_path still resolves to it;
|
|
14
|
+
// 3. the boot move carries tarballs from the old in-package path, keeping their
|
|
15
|
+
// <key>/ place, once, without overwriting, skipping anything not an artifact,
|
|
16
|
+
// and is a no-op in a single checkout or under MODULE_STORE_DIR;
|
|
17
|
+
// 4. the store keeps the instance's git tree clean (upgrade pre-flight).
|
|
18
|
+
//
|
|
19
|
+
// Run: node tests/module_store_instance_root.mjs
|
|
20
|
+
|
|
21
|
+
import { strict as assert } from 'node:assert';
|
|
22
|
+
import { createRequire } from 'node:module';
|
|
23
|
+
import { spawnSync } from 'node:child_process';
|
|
24
|
+
import os from 'node:os';
|
|
25
|
+
import path from 'node:path';
|
|
26
|
+
import fs from 'node:fs';
|
|
27
|
+
import { fileURLToPath } from 'node:url';
|
|
28
|
+
|
|
29
|
+
const require = createRequire(import.meta.url);
|
|
30
|
+
const REPO = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
31
|
+
const REAL = (...p) => path.join(REPO, 'src', ...p);
|
|
32
|
+
|
|
33
|
+
let passed = 0, failed = 0;
|
|
34
|
+
async function t(name, fn) {
|
|
35
|
+
try { await fn(); passed++; console.log(` PASS ${name}`); }
|
|
36
|
+
catch (e) { failed++; console.log(` FAIL ${name}\n ${e && e.stack ? e.stack.split('\n').slice(0, 3).join('\n ') : e}`); }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const TGZ = Buffer.from('1f8b0800000000000003', 'hex');
|
|
40
|
+
|
|
41
|
+
const WORK = fs.mkdtempSync(path.join(os.tmpdir(), 'ms-instance-root-'));
|
|
42
|
+
const CONSUMER = path.join(WORK, 'consumer');
|
|
43
|
+
const CORE = path.join(CONSUMER, 'node_modules', '@bongos', 'core');
|
|
44
|
+
const reexport = (real) => `module.exports = require(${JSON.stringify(real)});\n`;
|
|
45
|
+
|
|
46
|
+
// "npm ci": (re)install the fake core package — the real store, its requires stubbed through.
|
|
47
|
+
function installCore() {
|
|
48
|
+
fs.rmSync(CORE, { recursive: true, force: true });
|
|
49
|
+
fs.mkdirSync(path.join(CORE, 'src', 'bongos'), { recursive: true });
|
|
50
|
+
fs.mkdirSync(path.join(CORE, 'src', 'module-loader'), { recursive: true });
|
|
51
|
+
fs.copyFileSync(REAL('bongos', 'module-store.js'), path.join(CORE, 'src', 'bongos', 'module-store.js'));
|
|
52
|
+
fs.writeFileSync(path.join(CORE, 'src', 'module-loader', 'semver.js'), reexport(REAL('module-loader', 'semver.js')));
|
|
53
|
+
fs.writeFileSync(path.join(CORE, 'src', 'module-loader', 'manifest-schema.js'), reexport(REAL('module-loader', 'manifest-schema.js')));
|
|
54
|
+
fs.writeFileSync(path.join(CORE, 'src', 'instance-config.js'), reexport(REAL('instance-config.js')));
|
|
55
|
+
}
|
|
56
|
+
// A fresh require of the INSTALLED copy (a restarted server).
|
|
57
|
+
function loadInstalled() {
|
|
58
|
+
const p = path.join(CORE, 'src', 'bongos', 'module-store.js');
|
|
59
|
+
delete require.cache[p];
|
|
60
|
+
return require(p);
|
|
61
|
+
}
|
|
62
|
+
const inside = (child, parent) => {
|
|
63
|
+
const rel = path.relative(path.resolve(parent), path.resolve(child));
|
|
64
|
+
return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
fs.mkdirSync(CONSUMER, { recursive: true });
|
|
68
|
+
installCore();
|
|
69
|
+
delete process.env.MODULE_STORE_DIR;
|
|
70
|
+
process.env.BONGOS_INSTANCE_ROOT = CONSUMER;
|
|
71
|
+
|
|
72
|
+
await t('consumer layout: the store is <instance>/var/module-store, NOT inside the core package', () => {
|
|
73
|
+
const ms = loadInstalled();
|
|
74
|
+
const dir = ms.storeDir();
|
|
75
|
+
assert.equal(path.resolve(dir), path.join(CONSUMER, 'var', 'module-store'));
|
|
76
|
+
assert.ok(!inside(dir, CORE), `store ${dir} is inside the core package ${CORE}`);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
await t('MODULE_STORE_DIR still overrides', () => {
|
|
80
|
+
const ms = loadInstalled();
|
|
81
|
+
assert.equal(ms.storeDirFor({ env: { MODULE_STORE_DIR: '/elsewhere/x' } }), '/elsewhere/x');
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
await t('the checkout copy resolves the same way: an instance root, or the override', () => {
|
|
85
|
+
const { storeDir, storeDirFor } = require('../src/bongos/module-store.js');
|
|
86
|
+
assert.equal(storeDirFor({ env: {}, root: '/inst' }), path.join('/inst', 'var', 'module-store'));
|
|
87
|
+
assert.equal(path.resolve(storeDir()), path.join(CONSUMER, 'var', 'module-store'));
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
let row;
|
|
91
|
+
await t('a published tarball survives a core upgrade (package deleted and reinstalled)', async () => {
|
|
92
|
+
const ms = loadInstalled();
|
|
93
|
+
const staged = await ms.stageArtifact(TGZ, { key: 'weather', version: '1.2.0' });
|
|
94
|
+
await ms.commitArtifact(staged);
|
|
95
|
+
row = { module_key: 'weather', version: '1.2.0', artifact_path: ms.relativeArtifactPath(staged.final) };
|
|
96
|
+
assert.equal(row.artifact_path, 'weather/weather-1.2.0.tgz');
|
|
97
|
+
installCore(); // the upgrade
|
|
98
|
+
const after = loadInstalled();
|
|
99
|
+
const file = after.versionArtifactFile(row);
|
|
100
|
+
assert.ok(fs.existsSync(file), 'the tarball is gone after the upgrade');
|
|
101
|
+
assert.deepEqual(fs.readFileSync(file), TGZ);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
await t('the default store carries a * .gitignore, so the instance git tree stays clean', () => {
|
|
105
|
+
const gi = fs.readFileSync(path.join(CONSUMER, 'var', 'module-store', '.gitignore'), 'utf8');
|
|
106
|
+
assert.match(gi, /^\*$/m);
|
|
107
|
+
const init = spawnSync('git', ['init', '-q'], { cwd: CONSUMER, encoding: 'utf8' });
|
|
108
|
+
if (init.status !== 0) { console.log(' (git unavailable: .gitignore content checked only)'); return; }
|
|
109
|
+
fs.writeFileSync(path.join(CONSUMER, '.gitignore'), 'node_modules/\n');
|
|
110
|
+
const st = spawnSync('git', ['status', '--porcelain', '--untracked-files=all', '--', 'var'], { cwd: CONSUMER, encoding: 'utf8' });
|
|
111
|
+
assert.equal(st.status, 0);
|
|
112
|
+
assert.equal(st.stdout.trim(), '', `var/ shows in git status:\n${st.stdout}`);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
await t('boot move: tarballs at the old in-package path move into the instance store, once', async () => {
|
|
116
|
+
const ms = loadInstalled();
|
|
117
|
+
const legacy = path.join(CORE, 'var', 'module-store');
|
|
118
|
+
fs.mkdirSync(path.join(legacy, 'notes'), { recursive: true });
|
|
119
|
+
fs.mkdirSync(path.join(legacy, 'weather'), { recursive: true });
|
|
120
|
+
fs.mkdirSync(path.join(legacy, 'Bad Key'), { recursive: true });
|
|
121
|
+
fs.writeFileSync(path.join(legacy, 'notes', 'notes-0.1.0.tgz'), TGZ);
|
|
122
|
+
fs.writeFileSync(path.join(legacy, 'notes', 'notes-0.1.0.tgz.123.456.ab.partial'), 'half');
|
|
123
|
+
fs.writeFileSync(path.join(legacy, 'notes', 'other-0.1.0.tgz'), 'not this key');
|
|
124
|
+
fs.writeFileSync(path.join(legacy, 'Bad Key', 'Bad Key-1.0.0.tgz'), 'x');
|
|
125
|
+
// weather 1.2.0 already exists in the new store: kept, never overwritten.
|
|
126
|
+
fs.writeFileSync(path.join(legacy, 'weather', 'weather-1.2.0.tgz'), 'legacy copy');
|
|
127
|
+
|
|
128
|
+
const r = await ms.migrateLegacyStore();
|
|
129
|
+
assert.deepEqual(r, { moved: 1, skipped: 1, failed: 0 });
|
|
130
|
+
const store = ms.storeDir();
|
|
131
|
+
assert.deepEqual(fs.readFileSync(path.join(store, 'notes', 'notes-0.1.0.tgz')), TGZ);
|
|
132
|
+
assert.ok(!fs.existsSync(path.join(legacy, 'notes', 'notes-0.1.0.tgz')), 'moved, not copied');
|
|
133
|
+
assert.deepEqual(fs.readFileSync(path.join(store, 'weather', 'weather-1.2.0.tgz')), TGZ, 'the existing file is not overwritten');
|
|
134
|
+
assert.ok(!fs.existsSync(path.join(store, 'notes', 'notes-0.1.0.tgz.123.456.ab.partial')), 'a .partial is not an artifact');
|
|
135
|
+
assert.ok(!fs.existsSync(path.join(store, 'Bad Key')), 'a directory that is not a module key is left alone');
|
|
136
|
+
|
|
137
|
+
// A moved tarball is served through its unchanged registry row.
|
|
138
|
+
const moved = ms.versionArtifactFile({ module_key: 'notes', version: '0.1.0', artifact_path: 'notes/notes-0.1.0.tgz' });
|
|
139
|
+
assert.ok(fs.existsSync(moved));
|
|
140
|
+
|
|
141
|
+
const again = await ms.migrateLegacyStore();
|
|
142
|
+
assert.equal(again.moved, 0, 'the second boot moves nothing');
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
await t('boot move is a no-op in a single checkout and under MODULE_STORE_DIR', async () => {
|
|
146
|
+
const ms = loadInstalled();
|
|
147
|
+
const dir = fs.mkdtempSync(path.join(WORK, 'same-'));
|
|
148
|
+
fs.mkdirSync(path.join(dir, 'notes'));
|
|
149
|
+
fs.writeFileSync(path.join(dir, 'notes', 'notes-0.2.0.tgz'), TGZ);
|
|
150
|
+
assert.deepEqual(await ms.migrateLegacyStore({ from: dir, to: dir }), { moved: 0, skipped: 0, failed: 0 });
|
|
151
|
+
assert.deepEqual(await ms.migrateLegacyStore({ env: { MODULE_STORE_DIR: dir } }), { moved: 0, skipped: 0, failed: 0 });
|
|
152
|
+
assert.ok(fs.existsSync(path.join(dir, 'notes', 'notes-0.2.0.tgz')));
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
await t('the modules router runs the boot move when it is built', () => {
|
|
156
|
+
const src = fs.readFileSync(path.join(REPO, 'src', 'bongos', 'routes', 'modules.js'), 'utf8');
|
|
157
|
+
const body = src.slice(src.indexOf('module.exports = function buildModulesRouter('));
|
|
158
|
+
assert.ok(body.indexOf('migrateLegacyStore()') > 0 && body.indexOf('migrateLegacyStore()') < body.indexOf('express.Router()'));
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
fs.rmSync(WORK, { recursive: true, force: true });
|
|
162
|
+
console.log(`\n${passed} passed, ${failed} failed`);
|
|
163
|
+
if (failed) process.exitCode = 1;
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// tests/start_goal_first.mjs — /builder-start leads with the builder's goals
|
|
2
|
+
// (task 1002852).
|
|
3
|
+
//
|
|
4
|
+
// The owner's rules (2026-10-08): the joined goals sit ABOVE the claimable table
|
|
5
|
+
// and never replace it, and a task with no goal is listed under "Other work".
|
|
6
|
+
// These drive the pure helpers in scripts/gds/start-goals.js and the real table
|
|
7
|
+
// renderer start.js uses, so the property that matters — every claimable row is
|
|
8
|
+
// still listed, exactly once — is checked on what the builder actually sees.
|
|
9
|
+
|
|
10
|
+
import { strict as assert } from 'node:assert';
|
|
11
|
+
import { createRequire } from 'node:module';
|
|
12
|
+
import { makeRunner } from './helpers.mjs';
|
|
13
|
+
|
|
14
|
+
const require = createRequire(import.meta.url);
|
|
15
|
+
const { test, summary } = makeRunner();
|
|
16
|
+
const sg = require('../scripts/gds/start-goals.js');
|
|
17
|
+
|
|
18
|
+
const GOALS = [
|
|
19
|
+
{ id: 10, title: 'Alpha', status: 'open', viewer_is_member: false },
|
|
20
|
+
{ id: 20, title: 'Mine', status: 'open', viewer_is_member: true },
|
|
21
|
+
{ id: 30, title: 'Done one', status: 'achieved', viewer_is_member: true },
|
|
22
|
+
{ id: 40, title: 'Also mine', status: 'open', viewer_is_member: true },
|
|
23
|
+
];
|
|
24
|
+
const ROWS = [
|
|
25
|
+
{ id: 1, goal_id: 10, title: 'a1' },
|
|
26
|
+
{ id: 2, goal_id: null, title: 'loose' },
|
|
27
|
+
{ id: 3, goal_id: 20, title: 'm1' },
|
|
28
|
+
{ id: 4, goal_id: 99, title: 'unknown goal' },
|
|
29
|
+
{ id: 5, goal_id: 20, title: 'm2' },
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
await test('joined goals are the open ones the server says the viewer is a member of', () => {
|
|
33
|
+
assert.deepEqual(sg.joinedGoals(GOALS).map((g) => g.id), [20, 40]);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
await test('groups: joined goals first, then other goals, then "Other work" last; every row exactly once', () => {
|
|
37
|
+
const groups = sg.groupByGoal(ROWS, GOALS);
|
|
38
|
+
assert.deepEqual(groups.map((g) => g.title), ['Mine', 'Alpha', 'Goal 99', sg.OTHER_WORK]);
|
|
39
|
+
assert.deepEqual(groups[0].rows.map((r) => r.id), [3, 5], 'server order kept inside a group');
|
|
40
|
+
assert.equal(groups[0].joined, true);
|
|
41
|
+
assert.equal(groups[3].goal_id, null);
|
|
42
|
+
const ids = groups.flatMap((g) => g.rows.map((r) => r.id)).sort();
|
|
43
|
+
assert.deepEqual(ids, [1, 2, 3, 4, 5]);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
await test('with no goal list (a failed read) nothing is hidden: goals by id, goal-less under Other work', () => {
|
|
47
|
+
const groups = sg.groupByGoal(ROWS, []);
|
|
48
|
+
assert.equal(groups.flatMap((g) => g.rows).length, ROWS.length);
|
|
49
|
+
assert.equal(groups.at(-1).title, sg.OTHER_WORK);
|
|
50
|
+
assert.ok(groups.every((g) => !g.joined));
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
await test('joinedFirst moves the joined goals\' rows ahead of the cap and drops nothing', () => {
|
|
54
|
+
const out = sg.joinedFirst(ROWS, GOALS);
|
|
55
|
+
assert.deepEqual(out.map((r) => r.id), [3, 5, 1, 2, 4]);
|
|
56
|
+
assert.deepEqual(sg.joinedFirst(ROWS, []).map((r) => r.id), [1, 2, 3, 4, 5], 'no joined goal: order unchanged');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
await test('"Your goals" counts claimable work per joined goal from the full set', () => {
|
|
60
|
+
const y = sg.yourGoals(GOALS, ROWS);
|
|
61
|
+
assert.deepEqual(y.goals, [
|
|
62
|
+
{ id: '20', title: 'Mine', claimable: 2 },
|
|
63
|
+
{ id: '40', title: 'Also mine', claimable: 0 },
|
|
64
|
+
]);
|
|
65
|
+
assert.equal(y.more, 0);
|
|
66
|
+
assert.equal(sg.claimableWord(0), 'nothing to claim right now');
|
|
67
|
+
assert.equal(sg.claimableWord(2), '2 to claim');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
await test('a builder in no goal gets no goals list, and the table still lists everything', () => {
|
|
71
|
+
const none = GOALS.map((g) => ({ ...g, viewer_is_member: false }));
|
|
72
|
+
assert.deepEqual(sg.yourGoals(none, ROWS).goals, []);
|
|
73
|
+
assert.equal(sg.groupByGoal(ROWS, none).flatMap((g) => g.rows).length, ROWS.length);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
await test('start.js renders "Your goals" ABOVE the table and groups the table by goal (the wiring)', () => {
|
|
77
|
+
const fs = require('node:fs');
|
|
78
|
+
const src = fs.readFileSync(require.resolve('../scripts/gds/start.js'), 'utf8');
|
|
79
|
+
const goalsAt = src.indexOf("surface.strong('Your goals')");
|
|
80
|
+
const tableAt = src.indexOf("surface.strong('Claim one to start')");
|
|
81
|
+
assert.ok(goalsAt > 0 && goalsAt < tableAt, 'markdown: goals before the table');
|
|
82
|
+
const wGoals = src.indexOf('${goalsBlock}');
|
|
83
|
+
const wMenu = src.indexOf('<div class="bs-menuh">Claim one to start');
|
|
84
|
+
assert.ok(wGoals > 0 && wGoals < wMenu, 'widget: goals before the table');
|
|
85
|
+
assert.match(src, /claimableTable\(cardRows, [^)]*, goals\)/, 'the markdown table is grouped');
|
|
86
|
+
assert.equal((src.match(/\n\s+goals,\n\s+yourGoals,\n/g) || []).length, 2, 'both widget calls carry the goals');
|
|
87
|
+
assert.match(src, /startGoals\.joinedFirst\(filtered, goals\)\.slice\(0, limit\)/);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
summary();
|