@bongos/core 1.20.82 → 1.21.1
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 +154 -64
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +4 -0
- package/clients/bongos-client/index.cjs +4 -0
- package/clients/bongos-client/index.d.ts +7 -0
- package/clients/bongos-client/index.mjs +4 -0
- package/docs/adr/0051-full-session-transcript-corpus.md +3 -1
- package/docs/adr/0361-merges-publish-as-candidates-release-decides-what-others-are-offered.md +3 -3
- package/docs/adr/README.md +1 -1
- package/docs/api/openapi.json +153 -4
- package/docs/api-reference.md +5 -3
- package/docs/architecture.md +3 -0
- package/docs/copy-inventory.md +10 -10
- package/docs/copy-registry.json +12 -12
- package/docs/design/project-startup-direction.md +63 -0
- package/docs/file-map.md +2 -0
- package/docs/module-api-changelog.md +8 -0
- package/docs/page-inventory.json +8 -3
- package/docs/page-readings.json +382 -373
- package/docs/recipes/private-npm-distribution.md +1 -1
- package/migrations/core_269_session_transcripts.sql +35 -0
- package/modules/hall-ui/public/genesis-home.js +17 -3
- package/modules/hall-ui/public/index-genesis-demo.states.json +13 -0
- package/modules/lifecycle/done-when.js +82 -42
- package/modules/lifecycle/workflow-dispatch.js +21 -11
- package/modules/npm-release/release.js +10 -1
- package/modules/onboarding/founding-birth.js +116 -0
- package/modules/onboarding/genesis-home.js +72 -5
- package/modules/onboarding/port.js +9 -2
- package/modules/onboarding/routes/onboarding.js +8 -1
- package/modules/provisioning/birth.js +64 -0
- package/modules/provisioning/dns-resolves.js +54 -0
- package/modules/provisioning/migrations/provisioning_036_dns_resolved.sql +30 -0
- package/modules/provisioning/pollers/liveness-sweep.js +5 -0
- package/modules/provisioning/provisioning.js +7 -6
- package/modules/provisioning/routes/provisioning.js +6 -2
- package/modules/provisioning/tests/liveness.mjs +48 -0
- package/modules/public-landing/public/projects-dns-pending.states.json +85 -0
- package/modules/public-landing/public/projects.html +40 -15
- package/modules/sessions/db.js +58 -0
- package/modules/sessions/routes/sessions.js +97 -0
- package/modules/ui-design/kit/fixtures/founding-genesis-demo.json +176 -0
- package/modules/ui-design/kit/fixtures/me-founding-demo.json +27 -0
- package/modules/ui-design/kit/fixtures/provisioning-instances-demo.json +3 -0
- package/modules/ui-design/kit/fixtures/provisioning-instances-dns-pending.json +49 -0
- package/modules/ui-design/kit/fixtures/provisioning-instances.json +3 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +41 -0
- package/scripts/gds/provision.js +77 -53
- package/scripts/gds/session-digest.js +11 -3
- package/scripts/gds/session-transcript-build.js +155 -0
- package/scripts/gds/session-transcript-upload.js +29 -0
- package/scripts/gds/ship-session-upload.js +6 -1
- package/scripts/gds/upgrade-commit-identity.js +32 -0
- package/scripts/gds/upgrade.js +1 -1
- package/src/bongos/routes.js +3 -0
- package/src/branding.js +9 -0
- package/src/module-api.js +1 -1
- package/tests/auto_satisfy_criteria.mjs +20 -0
- package/tests/founding_birth.mjs +200 -0
- package/tests/founding_mode.mjs +3 -1
- package/tests/genesis_home.mjs +71 -2
- package/tests/goal_closure_invariants.mjs +68 -0
- package/tests/hub_map_orb_states.mjs +5 -1
- package/tests/npm_release_release.mjs +37 -2
- package/tests/projects_hub.mjs +5 -3
- package/tests/projects_hub_dns_ready.mjs +153 -0
- package/tests/projects_hub_pre_uat.mjs +9 -5
- package/tests/provision_dns_first.mjs +171 -0
- package/tests/provision_settings_apply.mjs +6 -1
- package/tests/provisioning_settings_env.mjs +49 -0
- package/tests/session_transcript.mjs +273 -0
- package/tests/ui_design_kit.mjs +1 -1
- package/tests/upgrade_commit_identity.mjs +89 -0
- package/tests/wizard_demo.mjs +29 -3
|
@@ -57,7 +57,7 @@ npm publish ./clients/bongos-client # real (publishConfig makes it
|
|
|
57
57
|
|
|
58
58
|
**Publishing is automatic on merge** as of 2026-08-08: the `publish` workflow rides behind the required `unit` check and waits for the `integration` lane's verdict on the commit it releases (task 1004290), auto-bumps a patch when nobody bumped, builds through the fail-closed no-leak gate, publishes, and tags — see the workflow header (`.github/workflows/publish.yml`) and ADR 0161. It ships inert until the owner arms it: repo variable `PUBLISH_ON_MERGE=1`, a **trusted publisher** registered on npmjs.com for `@bongos/core` naming this repo + `publish.yml`, and the secret `RELEASE_PUSH_TOKEN` (contents:write, for the bump commit). There is deliberately **no** `NPM_TOKEN` — the lane authenticates by OIDC, and the workflow sets neither `NPM_TOKEN` nor `NODE_AUTH_TOKEN` because either one would send npm down the legacy token path and skip the OIDC exchange entirely. A **minor/major bump stays a human in-task edit**. The update subscription ([ADR 0136](../adr/0136-update-channel-subscription-policy.md)) consumes whatever is published, on its next sweep.
|
|
59
59
|
|
|
60
|
-
**Candidates and Release** ([ADR 0361](../adr/0361-merges-publish-as-candidates-release-decides-what-others-are-offered.md), task 1004298). With the repo variable `PUBLISH_AS_CANDIDATE=1`, each merge publishes under the `next` label: the platform hall runs it, and no other project is offered it. **Release** on /deploy runs `publish.yml`'s `release` job (`workflow_dispatch`, input `release_version`), which moves `latest` to that version over the same OIDC trusted publisher. It needs **Allow npm dist-tag** ticked on the `publish.yml` trusted-publisher entry, and the server's GitHub
|
|
60
|
+
**Candidates and Release** ([ADR 0361](../adr/0361-merges-publish-as-candidates-release-decides-what-others-are-offered.md), task 1004298). With the repo variable `PUBLISH_AS_CANDIDATE=1`, each merge publishes under the `next` label: the platform hall runs it, and no other project is offered it. **Release** on /deploy runs `publish.yml`'s `release` job (`workflow_dispatch`, input `release_version`), which moves `latest` to that version over the same OIDC trusted publisher. It needs **Allow npm dist-tag** ticked on the `publish.yml` trusted-publisher entry, and the server's GitHub credential needs **Actions: write** to start the job: the `GITHUB_PUSH_TOKEN` access token (what cloudbongos.com uses), or the GitHub App where one is configured. From a terminal the same job runs as `gh workflow run publish.yml -f release_version=<x.y.z>`. The arming order is in the ADR.
|
|
61
61
|
|
|
62
62
|
The steps below remain as the **manual fallback** (lane disarmed, npm outage, or a deliberate out-of-band release):
|
|
63
63
|
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
-- core_269_session_transcripts.sql — the FULL, secret-scrubbed transcript of every
|
|
2
|
+
-- session (ADR 0051, task 1001004).
|
|
3
|
+
--
|
|
4
|
+
-- WHY A SEPARATE TABLE and not a widened session_records.detail / a new jsonb
|
|
5
|
+
-- column on it: session_records is the BFG's hot, searched, paged table
|
|
6
|
+
-- (searchSessions, summarizeSessions, /mine all SELECT from it, and its rows are
|
|
7
|
+
-- KB by design — ADR 0027). A transcript is MB. Putting it on the same row would
|
|
8
|
+
-- make every list query drag (or have to remember to exclude) a fat jsonb, and
|
|
9
|
+
-- would make a TOASTed-column slip on one query a performance incident. One row
|
|
10
|
+
-- per session in each table, joined on session_id, keeps the evaluator's read
|
|
11
|
+
-- path exactly as cheap as it is today. ADR 0051 §1 allowed either shape
|
|
12
|
+
-- ("new transcript jsonb column, or a widened detail"); this is the third
|
|
13
|
+
-- option that satisfies its intent without the cost.
|
|
14
|
+
--
|
|
15
|
+
-- The content is scrubbed ON THE BUILDER'S BOX before upload (modules/security/
|
|
16
|
+
-- secret-scrub.js, the task-1003 scrubber); this table never holds raw text.
|
|
17
|
+
--
|
|
18
|
+
-- builder_id is denormalised from session_records ON PURPOSE: the read route's
|
|
19
|
+
-- ownership check (own transcript vs Archon-only cross-builder) must not depend
|
|
20
|
+
-- on a join succeeding, and the upload's ownership guard needs it at write time.
|
|
21
|
+
--
|
|
22
|
+
-- Retention: keep forever (ADR 0051 §4, Lars 2026-06-13). Storage-cost work is a
|
|
23
|
+
-- deliberate future task, not designed in here.
|
|
24
|
+
|
|
25
|
+
CREATE TABLE IF NOT EXISTS session_transcripts (
|
|
26
|
+
session_id text PRIMARY KEY, -- Claude Code session id, == session_records.session_id
|
|
27
|
+
builder_id bigint NOT NULL REFERENCES builders(id),
|
|
28
|
+
turn_count integer NOT NULL DEFAULT 0,
|
|
29
|
+
byte_size integer NOT NULL DEFAULT 0, -- serialized size of `turns`, for corpus accounting
|
|
30
|
+
truncated boolean NOT NULL DEFAULT false, -- true when the uploader hit its own size ceiling
|
|
31
|
+
turns jsonb NOT NULL DEFAULT '[]'::jsonb, -- per-turn scrubbed content (prompts, assistant text, tool calls + results)
|
|
32
|
+
uploaded_at timestamptz NOT NULL DEFAULT now()
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
CREATE INDEX IF NOT EXISTS idx_session_transcripts_builder ON session_transcripts (builder_id);
|
|
@@ -79,7 +79,7 @@
|
|
|
79
79
|
var now = g.stages.find(function (s) { return s.state === 'now'; });
|
|
80
80
|
var sub = g.deciding
|
|
81
81
|
? 'Deciding the plan' + (g.day ? ' · day ' + g.day : '')
|
|
82
|
-
: 'Stage ' + g.stage_number + ' of ' + g.stage_count + ' · ' + (now ? now.label.toLowerCase() : g.stage) + (g.day ? ' · day ' + g.day : '');
|
|
82
|
+
: 'Stage ' + g.stage_number + ' of ' + g.stage_count + ' · ' + (now ? now.label.toLowerCase() : g.stage) + (g.demo ? ' · demo' : g.day ? ' · day ' + g.day : '');
|
|
83
83
|
return '<div class="genesis__head"><h2 id="genesis-h" class="scroll__h">Genesis</h2>'
|
|
84
84
|
+ '<p class="genesis__sub">' + esc(sub) + '</p></div>' + stepperHtml(g);
|
|
85
85
|
}
|
|
@@ -157,7 +157,10 @@
|
|
|
157
157
|
function stagesHtml(g) {
|
|
158
158
|
return '<ol class="genesis__stages">' + g.stages.map(function (s) {
|
|
159
159
|
var wakes = (s.wakes || []).map(function (id) { return ROOM[id] || id; });
|
|
160
|
-
|
|
160
|
+
// A demo's stage is minutes long (task 1004506): say how long, and due at a time.
|
|
161
|
+
var due = s.minutes
|
|
162
|
+
? ' · ' + s.minutes + ' min' + (s.state === 'now' && s.due ? ', due ' + new Date(s.due).toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' }) : '')
|
|
163
|
+
: s.state === 'now' && s.due ? ' · due ' + new Date(s.due).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) : '';
|
|
161
164
|
return '<li class="genesis__stage is-' + s.state + '">'
|
|
162
165
|
+ '<span class="genesis__stage-n">0' + s.number + '</span>'
|
|
163
166
|
+ '<span class="genesis__stage-t"><b>' + esc(s.label) + '</b>'
|
|
@@ -225,12 +228,23 @@
|
|
|
225
228
|
return document.documentElement.getAttribute('data-founding') === 'on';
|
|
226
229
|
}
|
|
227
230
|
|
|
231
|
+
// A demo's genesis is expedited (task 1004506): its plan was settled from the demo's
|
|
232
|
+
// own picks before anyone saw it, so it never shows the decide form, only this line.
|
|
233
|
+
function demoHtml(g) {
|
|
234
|
+
var d = g.demo;
|
|
235
|
+
if (!d) return '';
|
|
236
|
+
var total = g.stages.reduce(function (n, s) { return n + (s.minutes || 0); }, 0);
|
|
237
|
+
return '<p class="scroll__lede">A demo’s genesis is short: about ' + esc(String(total)) + ' minutes, then the rest of your time is for building. '
|
|
238
|
+
+ 'The plan is already set from your demo: Monarchy / BDFL, ' + esc(String(d.people)) + (d.people === 1 ? ' person, ' : ' people, ')
|
|
239
|
+
+ esc(String(d.hours_each)) + (d.hours_each === 1 ? ' hour' : ' hours') + ' each. Finish each stage’s task, then approve it to move on.</p>';
|
|
240
|
+
}
|
|
241
|
+
|
|
228
242
|
function render(host, state) {
|
|
229
243
|
var g = state.genesis;
|
|
230
244
|
if (!g) { host.hidden = true; return; }
|
|
231
245
|
host.innerHTML = headHtml(g) + (g.deciding
|
|
232
246
|
? decideHtml(g, state.gov)
|
|
233
|
-
: lifeHtml(g) + '<div class="genesis__grid">' + stagesHtml(g) + nowHtml(g, state.viewer) + '</div>');
|
|
247
|
+
: demoHtml(g) + lifeHtml(g) + '<div class="genesis__grid">' + stagesHtml(g) + nowHtml(g, state.viewer) + '</div>');
|
|
234
248
|
host.hidden = false;
|
|
235
249
|
}
|
|
236
250
|
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
{
|
|
2
|
+
"_": "The genesis home of a DEMO, born into genesis (task 1004506): 3 people, 4 hours each. The plan was settled at birth from the demo's picks, so there is no decide form: one line names the plan, and each stage reads in minutes (scope in progress, due at a time). /me says founding at scope (kit fixture me-founding-demo.json) and /founding/genesis answers founding-genesis-demo.json, built from the real server shaper. A real project's genesis is index-genesis.states.json and index-genesis-decide.states.json; a hall that is not founding is index.states.json.",
|
|
3
|
+
"page": "/builders/",
|
|
4
|
+
"surface": "hall-ui",
|
|
5
|
+
"stub": { "prefix": "/builders/", "fixtures": { "me": "me-founding-demo", "founding-genesis": "founding-genesis-demo" } },
|
|
6
|
+
"modeQuery": false,
|
|
7
|
+
"ignoreRequests": ["/api/(gds|bongos)/me$", "/api/(gds|bongos)/me/", "/api/(gds|bongos)/tasks", "/api/(gds|bongos)/claims", "/api/(gds|bongos)/goals", "/api/(gds|bongos)/inbox", "/api/(gds|bongos)/live", "/api/(gds|bongos)/analytics/", "/api/(gds|bongos)/achievements", "/api/(gds|bongos)/versions", "/api/(gds|bongos)/leaderboard", "/api/(gds|bongos)/removal-proposals", "/api/(gds|bongos)/blockers"],
|
|
8
|
+
"_ignored": "removal-proposals and blockers are fixtures the kit stub does not carry; index.states.json's home render reports the same two 404s.",
|
|
9
|
+
"ignoreConsole": ["/api/(gds|bongos)/", "\\b401\\b", "\\b404\\b", "Unauthorized", "Not Found", "load failed"],
|
|
10
|
+
"states": {
|
|
11
|
+
"demo": { "auth": true, "actions": [["wait", 800]], "expect": { "visible": ["#genesis-home", "#founding-band"] } }
|
|
12
|
+
}
|
|
13
|
+
}
|
|
@@ -518,42 +518,91 @@ async function autoSatisfyShippedCriteria(exec, { taskId = null, criterionId = n
|
|
|
518
518
|
c.satisfied_by_task_id`,
|
|
519
519
|
[scopedTaskId, scopedCriterionId]
|
|
520
520
|
);
|
|
521
|
-
|
|
521
|
+
// THE SHIP THAT FLIPS NO CRITERION STILL FINISHES ITS GOAL (task 1004513).
|
|
522
|
+
// Returning here unconditionally was a hole. When every criterion of a goal was
|
|
523
|
+
// satisfied BEFORE its last task shipped, the UPDATE above matches nothing — and
|
|
524
|
+
// the other writer (`POST /done-when/:id/satisfy` → achieveGoalIfComplete) had
|
|
525
|
+
// already run too early, while that task was still open, so it correctly held
|
|
526
|
+
// the goal open and then nobody ever re-asked. The goal sat `open` with every
|
|
527
|
+
// criterion satisfied and no work left, until a criterion was satisfied a second
|
|
528
|
+
// time purely to re-trigger the other door.
|
|
529
|
+
//
|
|
530
|
+
// So on the SCOPED ship path, re-check the shipping task's OWN goal against the
|
|
531
|
+
// same rule the cascade below applies. `exec` is load-bearing here for the same
|
|
532
|
+
// reason it is there: this task's flip to `shipped` is uncommitted, and on the
|
|
533
|
+
// module pool the task check would still read `confirmed` — non-terminal — and
|
|
534
|
+
// hold the goal open exactly as before.
|
|
535
|
+
//
|
|
536
|
+
// The UNSCOPED sweep keeps its cheap early return: it holds no task, so there is
|
|
537
|
+
// no candidate goal to name, and every goal it can close it closes through the
|
|
538
|
+
// cascade below.
|
|
539
|
+
if (criteria.length === 0) {
|
|
540
|
+
if (scopedTaskId == null) return { criteria: [], goals: [] };
|
|
541
|
+
const { rows: own } = await exec.query(
|
|
542
|
+
`SELECT goal_id FROM tasks WHERE id = $1::bigint AND goal_id IS NOT NULL`,
|
|
543
|
+
[scopedTaskId]
|
|
544
|
+
);
|
|
545
|
+
const goals = await achieveCompletedGoals(exec, own.map((r) => Number(r.goal_id)));
|
|
546
|
+
return { criteria: [], goals, versionsClosed: await autoCloseVersionsForGoals(exec, goals) };
|
|
547
|
+
}
|
|
522
548
|
|
|
523
549
|
// Cascade. Deliberately re-derived from the table rather than from `criteria`:
|
|
524
550
|
// a goal completes when ALL of its criteria are satisfied, and the ones that
|
|
525
|
-
// closed in THIS call may not be all of them.
|
|
526
|
-
//
|
|
527
|
-
// task (BV1.R09, task 1003596) — but on `exec`, so it sees the flips above
|
|
528
|
-
// inside the same transaction.
|
|
529
|
-
//
|
|
530
|
-
// THE THIRD CLAUSE, and why it must run HERE and not only in db-goals.js. This
|
|
531
|
-
// is the path that actually fires on every ship (`db-ship.js`) and every
|
|
532
|
-
// reconciler tick (`publish-reconciler.js`); `achieveGoalIfComplete` covers the
|
|
533
|
-
// manual `POST /done-when/:id/satisfy`. The two are a DELIBERATE duplicate —
|
|
534
|
-
// collapsing them into one helper creates a require cycle (db-goals requires
|
|
535
|
-
// done-when) — so the rule is written twice on purpose and both copies must
|
|
536
|
-
// move together, or a goal closes correctly through one door and wrongly
|
|
537
|
-
// through the other. ADR 0250 D2.
|
|
538
|
-
//
|
|
539
|
-
// `exec` is load-bearing for THIS clause specifically. The shipping task's flip
|
|
540
|
-
// to `shipped` is uncommitted at this point in the ship transaction; on the
|
|
541
|
-
// module pool the subquery would still see it as `confirmed` — non-terminal —
|
|
542
|
-
// and refuse to close a goal whose last task just landed. Every goal would then
|
|
543
|
-
// need a second, later trigger to close at all.
|
|
544
|
-
//
|
|
545
|
-
// Shipping is NOT blocked by any of this: the ship's own writes are untouched
|
|
546
|
-
// and this UPDATE simply matches no row, leaving the goal open.
|
|
547
|
-
//
|
|
548
|
-
// The subquery is index-served: migration 228 added idx_tasks_goal ON tasks
|
|
549
|
-
// (goal_id), so this is a small index scan per candidate goal, and the candidate
|
|
550
|
-
// set is only the goals whose criteria just closed in this call. (Raised in
|
|
551
|
-
// review off docs/architecture.md, whose tasks index list predated 228 and has
|
|
552
|
-
// been corrected.)
|
|
551
|
+
// closed in THIS call may not be all of them. The statement itself is
|
|
552
|
+
// achieveCompletedGoals, below.
|
|
553
553
|
const goalIds = [...new Set(criteria.map((c) => c.goal_id).filter((g) => g != null))].map(Number);
|
|
554
554
|
// No goal achieved, so no version can have just become closable.
|
|
555
555
|
if (goalIds.length === 0) return { criteria, goals: [], versionsClosed: [] };
|
|
556
|
-
const
|
|
556
|
+
const goals = await achieveCompletedGoals(exec, goalIds);
|
|
557
|
+
// BV1.R16 (task 1003603, ADR 0263 §§3–4): a version closes ITSELF once its last
|
|
558
|
+
// non-maintenance goal achieves. On the caller's `exec`, so it sees the flips
|
|
559
|
+
// above that have not committed yet — a check on the pool would never close the
|
|
560
|
+
// version on the ship that actually finished it.
|
|
561
|
+
//
|
|
562
|
+
// `autoCloseVersionsForGoals` catches per version and never rethrows: a version
|
|
563
|
+
// close must not be able to fail a builder's ship. The accepted failure is a
|
|
564
|
+
// version left un-closed, which the reconciler's next unscoped sweep heals.
|
|
565
|
+
const versionsClosed = await autoCloseVersionsForGoals(exec, goals);
|
|
566
|
+
return { criteria, goals, versionsClosed };
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
// achieveCompletedGoals — the ONE statement that flips a goal open→achieved on
|
|
570
|
+
// the ship/sweep side. Written once because autoSatisfyShippedCriteria now enters
|
|
571
|
+
// it from two places — the criteria this call just closed, and (task 1004513) the
|
|
572
|
+
// shipping task's own goal when it closed none — and two copies of this rule
|
|
573
|
+
// would be two things to keep in step. Takes the caller's `exec` and the candidate
|
|
574
|
+
// goal ids; returns the rows that actually flipped (usually none).
|
|
575
|
+
//
|
|
576
|
+
// Mirrors db.achieveGoalIfComplete exactly — open goals only, >=1 criterion, zero
|
|
577
|
+
// unsatisfied, AND no unfinished task (BV1.R09, task 1003596).
|
|
578
|
+
//
|
|
579
|
+
// THE THIRD CLAUSE, and why it must run HERE and not only in db-goals.js. This is
|
|
580
|
+
// the path that actually fires on every ship (`db-ship.js`) and every reconciler
|
|
581
|
+
// tick (`publish-reconciler.js`); `achieveGoalIfComplete` covers the manual
|
|
582
|
+
// `POST /done-when/:id/satisfy`. The two are a DELIBERATE duplicate — collapsing
|
|
583
|
+
// them into one helper creates a require cycle (db-goals requires done-when) — so
|
|
584
|
+
// the rule is written twice on purpose and both copies must move together, or a
|
|
585
|
+
// goal closes correctly through one door and wrongly through the other. ADR 0250
|
|
586
|
+
// D2.
|
|
587
|
+
//
|
|
588
|
+
// `exec` is load-bearing for THIS clause specifically. The shipping task's flip
|
|
589
|
+
// to `shipped` is uncommitted at this point in the ship transaction; on the module
|
|
590
|
+
// pool the subquery would still see it as `confirmed` — non-terminal — and refuse
|
|
591
|
+
// to close a goal whose last task just landed. Every goal would then need a
|
|
592
|
+
// second, later trigger to close at all.
|
|
593
|
+
//
|
|
594
|
+
// Shipping is NOT blocked by any of this: the ship's own writes are untouched and
|
|
595
|
+
// this UPDATE simply matches no row, leaving the goal open.
|
|
596
|
+
//
|
|
597
|
+
// The subquery is index-served: migration 228 added idx_tasks_goal ON tasks
|
|
598
|
+
// (goal_id), so this is a small index scan per candidate goal, and the candidate
|
|
599
|
+
// set is only the goals this call could possibly have completed. (Raised in review
|
|
600
|
+
// off docs/architecture.md, whose tasks index list predated 228 and has been
|
|
601
|
+
// corrected.)
|
|
602
|
+
async function achieveCompletedGoals(exec, goalIds) {
|
|
603
|
+
const ids = (goalIds || []).filter((g) => g != null).map(Number);
|
|
604
|
+
if (ids.length === 0) return [];
|
|
605
|
+
const { rows } = await exec.query(
|
|
557
606
|
`UPDATE goals g
|
|
558
607
|
SET status = 'achieved', updated_at = now()
|
|
559
608
|
WHERE g.id = ANY($1::bigint[])
|
|
@@ -564,21 +613,12 @@ async function autoSatisfyShippedCriteria(exec, { taskId = null, criterionId = n
|
|
|
564
613
|
AND ${nonTerminalSql()}
|
|
565
614
|
AND ${SMOKE_NOT_LIKE.replace(/title/, 't.title')})
|
|
566
615
|
RETURNING g.id, g.title, g.version_id, g.status`,
|
|
567
|
-
[
|
|
616
|
+
[ids]
|
|
568
617
|
);
|
|
569
618
|
// Task 1003660: an achieved goal takes no new members — close its pending asks
|
|
570
619
|
// on the same executor, so the cancel rides the ship transaction.
|
|
571
|
-
await cancelPendingMembershipRequests(exec,
|
|
572
|
-
|
|
573
|
-
// non-maintenance goal achieves. On the caller's `exec`, so it sees the flips
|
|
574
|
-
// above that have not committed yet — a check on the pool would never close the
|
|
575
|
-
// version on the ship that actually finished it.
|
|
576
|
-
//
|
|
577
|
-
// `autoCloseVersionsForGoals` catches per version and never rethrows: a version
|
|
578
|
-
// close must not be able to fail a builder's ship. The accepted failure is a
|
|
579
|
-
// version left un-closed, which the reconciler's next unscoped sweep heals.
|
|
580
|
-
const versionsClosed = await autoCloseVersionsForGoals(exec, goals);
|
|
581
|
-
return { criteria, goals, versionsClosed };
|
|
620
|
+
await cancelPendingMembershipRequests(exec, rows.map((g) => g.id));
|
|
621
|
+
return rows;
|
|
582
622
|
}
|
|
583
623
|
|
|
584
624
|
// describeAutoClose — the one-line summary of what a close actually closed, or
|
|
@@ -5,16 +5,21 @@
|
|
|
5
5
|
// Kept beside github-push.js rather than in it: it shares that file's credential and headers,
|
|
6
6
|
// and nothing else.
|
|
7
7
|
|
|
8
|
-
const {
|
|
8
|
+
const { getInstallationToken, ghHeaders, isSafeBranch } = require('./github-push');
|
|
9
9
|
const { repoInfo: { loadRepoInfo } } = require('../../src/module-api');
|
|
10
10
|
|
|
11
11
|
// dispatchWorkflow — start a GitHub Actions workflow on this repo (task 1004298, ADR 0361). The
|
|
12
12
|
// Release button on /deploy uses it to run publish.yml's release job, so the npm credential never
|
|
13
|
-
// leaves GitHub: the server
|
|
14
|
-
// Actions: write for this one call. Returns { ok:true } on GitHub's 204, otherwise
|
|
15
|
-
// { ok:false, code, status? } — UNCONFIGURED (no credential), NO_REPO_INFO, BAD_INPUT,
|
|
16
|
-
// (GitHub said no: 403 = the
|
|
17
|
-
// 422 = an input the workflow does not declare), UNREACHABLE. Never throws.
|
|
13
|
+
// leaves GitHub: the server uses only the GitHub credential it already holds, which needs
|
|
14
|
+
// Actions: write for this one call. Returns { ok:true, credential } on GitHub's 204, otherwise
|
|
15
|
+
// { ok:false, code, status?, credential? } — UNCONFIGURED (no credential), NO_REPO_INFO, BAD_INPUT,
|
|
16
|
+
// REFUSED (GitHub said no: 403 = the credential lacks Actions: write, 404 = no such workflow or no
|
|
17
|
+
// access, 422 = an input the workflow does not declare), UNREACHABLE. Never throws.
|
|
18
|
+
//
|
|
19
|
+
// `credential` says WHICH one was used — 'app' (a GitHub App installation token) or 'token' (the
|
|
20
|
+
// GITHUB_PUSH_TOKEN access token) — so a refusal can name the thing the owner must edit
|
|
21
|
+
// (task 1004518: cloudbongos.com uses the access token, and a message naming "the App" sent the
|
|
22
|
+
// owner to the wrong settings page). Resolved in resolveToken's order: App first, then the token.
|
|
18
23
|
//
|
|
19
24
|
// ONLY THE WORKFLOWS NAMED HERE can be started. The port lends the server's GitHub credential to
|
|
20
25
|
// another module; an allowlist keeps a buggy or future borrower from running any other workflow in
|
|
@@ -22,8 +27,13 @@ const { repoInfo: { loadRepoInfo } } = require('../../src/module-api');
|
|
|
22
27
|
const DISPATCHABLE_WORKFLOWS = Object.freeze(['publish.yml']);
|
|
23
28
|
async function dispatchWorkflow({ workflow, ref = 'main', inputs = {} }, deps = {}) {
|
|
24
29
|
if (!DISPATCHABLE_WORKFLOWS.includes(String(workflow || '')) || !isSafeBranch(ref)) return { ok: false, code: 'BAD_INPUT' };
|
|
25
|
-
let token;
|
|
26
|
-
|
|
30
|
+
let token = deps.token || null;
|
|
31
|
+
let credential = token ? (deps.credential || 'token') : null;
|
|
32
|
+
if (!token) {
|
|
33
|
+
try { token = await (deps.appToken || getInstallationToken)(deps); } catch (_) { return { ok: false, code: 'UNCONFIGURED', credential: 'app' }; }
|
|
34
|
+
if (token) credential = 'app';
|
|
35
|
+
}
|
|
36
|
+
if (!token && process.env.GITHUB_PUSH_TOKEN) { token = process.env.GITHUB_PUSH_TOKEN; credential = 'token'; }
|
|
27
37
|
if (!token) return { ok: false, code: 'UNCONFIGURED' };
|
|
28
38
|
const repoInfo = deps.repoInfo || loadRepoInfo();
|
|
29
39
|
if (!repoInfo || repoInfo.error) return { ok: false, code: 'NO_REPO_INFO' };
|
|
@@ -35,10 +45,10 @@ async function dispatchWorkflow({ workflow, ref = 'main', inputs = {} }, deps =
|
|
|
35
45
|
headers: { ...ghHeaders(token), 'Content-Type': 'application/json' },
|
|
36
46
|
body: JSON.stringify({ ref, inputs }),
|
|
37
47
|
});
|
|
38
|
-
if (res.status === 204 || res.ok) return { ok: true };
|
|
39
|
-
return { ok: false, code: 'REFUSED', status: res.status };
|
|
48
|
+
if (res.status === 204 || res.ok) return { ok: true, credential };
|
|
49
|
+
return { ok: false, code: 'REFUSED', status: res.status, credential };
|
|
40
50
|
} catch (_) {
|
|
41
|
-
return { ok: false, code: 'UNREACHABLE' };
|
|
51
|
+
return { ok: false, code: 'UNREACHABLE', credential };
|
|
42
52
|
}
|
|
43
53
|
}
|
|
44
54
|
|
|
@@ -95,6 +95,15 @@ async function readReleaseLedger(pool) {
|
|
|
95
95
|
}
|
|
96
96
|
}
|
|
97
97
|
|
|
98
|
+
// The credential a refusal names, in the words of the settings page the owner must open: GitHub
|
|
99
|
+
// lists an App under Developer settings → GitHub Apps, and an access token under Developer
|
|
100
|
+
// settings → Personal access tokens (task 1004518).
|
|
101
|
+
function credentialName(credential) {
|
|
102
|
+
if (credential === 'app') return "the server's GitHub App";
|
|
103
|
+
if (credential === 'token') return "the server's GitHub access token (GITHUB_PUSH_TOKEN, under Developer settings → Personal access tokens)";
|
|
104
|
+
return "the server's GitHub credential";
|
|
105
|
+
}
|
|
106
|
+
|
|
98
107
|
/**
|
|
99
108
|
* What the Release button does. Returns { status, body } for the route to send.
|
|
100
109
|
*
|
|
@@ -118,7 +127,7 @@ async function requestRelease({ version, registry, dispatch } = {}) {
|
|
|
118
127
|
}
|
|
119
128
|
const why = !r ? 'no answer'
|
|
120
129
|
: r.code === 'UNCONFIGURED' ? 'this server has no GitHub credential'
|
|
121
|
-
: r.code === 'REFUSED' && r.status === 403 ?
|
|
130
|
+
: r.code === 'REFUSED' && r.status === 403 ? `GitHub refused: ${credentialName(r.credential)} needs the Actions: write permission on this repository`
|
|
122
131
|
: r.code === 'REFUSED' && r.status === 404 ? 'GitHub could not find publish.yml, or the App cannot see this repository'
|
|
123
132
|
: r.code === 'REFUSED' ? `GitHub refused the request (${r.status})`
|
|
124
133
|
: r.code === 'UNREACHABLE' ? 'GitHub could not be reached'
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// modules/onboarding/founding-birth.js — A NEW PROJECT IS BORN INTO GENESIS (task
|
|
4
|
+
// 1004506 / BV2.PS18, goal 1000121; spec docs/specs/bongos-v2-project-startup.md step 5,
|
|
5
|
+
// D4, D8, D9).
|
|
6
|
+
//
|
|
7
|
+
// THE PROBLEM. POST /founding/start existed, but nothing called it, so a project made
|
|
8
|
+
// through the startup flow opened as an ordinary hall. The create request lands on the
|
|
9
|
+
// HUB, while founding mode lives in the NEW INSTANCE's own project_settings, and the hub
|
|
10
|
+
// holds no way into that database. So the hub only says so, and the instance acts:
|
|
11
|
+
//
|
|
12
|
+
// 1. The hub's create route stamps a reserved key on the new row
|
|
13
|
+
// (modules/provisioning/birth.js) — never on a row that already exists.
|
|
14
|
+
// 2. The runner writes it into the instance's web.env with the other settings
|
|
15
|
+
// companions: <PREFIX>_FOUNDING_BIRTH, plus <PREFIX>_DEMO_PEOPLE / _DEMO_HOURS
|
|
16
|
+
// when the row is a demo. src/branding.js maps them onto `founding.*`, which is
|
|
17
|
+
// server-only (clientBranding never publishes it).
|
|
18
|
+
// 3. Here, the instance reads that marker and, once, starts founding — and for a
|
|
19
|
+
// demo, also settles the expedited plan, so its decide stage needs no choices.
|
|
20
|
+
//
|
|
21
|
+
// WHEN IT RUNS: lazily, on the first founding read (GET /me through the port, and
|
|
22
|
+
// GET /founding/genesis), not at boot. The first /me is the hall's first paint after the
|
|
23
|
+
// founder's first sign-in, so the hall opens already in genesis; and by then the
|
|
24
|
+
// founder is seated and the kickoff seed has run, so the board the demo's stages are
|
|
25
|
+
// filed on exists. At boot it would race the module load order (the lifecycle port the
|
|
26
|
+
// plan files tasks through may not be registered yet) and a database that is still
|
|
27
|
+
// coming up.
|
|
28
|
+
//
|
|
29
|
+
// WHY IT CANNOT RACE OR TOUCH AN OLD PROJECT.
|
|
30
|
+
// * No marker, no run: a project created before this shipped has none (D8), and a
|
|
31
|
+
// marker-less read resolves without touching the database.
|
|
32
|
+
// * Once per process: one shared promise, so two concurrent first reads share one run.
|
|
33
|
+
// * Once for good: founding is started only when it was never started and never
|
|
34
|
+
// ended (founding.started_at / ended_at), so a restart, a later settings push that
|
|
35
|
+
// rewrites the env, or an owner who already started by hand changes nothing. A demo
|
|
36
|
+
// carried over keeps its state for the same reason: genesis happened once.
|
|
37
|
+
// * The demo's plan is saved only while the project is still deciding with no plan,
|
|
38
|
+
// and savePlan itself refuses outside 'decide'; the stage tasks are filed by a seeder
|
|
39
|
+
// that skips a step already filed.
|
|
40
|
+
|
|
41
|
+
const fm = require('./founding-mode');
|
|
42
|
+
const genesis = require('./genesis-home');
|
|
43
|
+
|
|
44
|
+
// The demo's limits and the bounds check are genesis-home.js's own, never retyped here.
|
|
45
|
+
const { DEMO_PEOPLE, DEMO_HOURS, intIn } = genesis;
|
|
46
|
+
|
|
47
|
+
// birthMarker(founding) — PURE. The resolved branding pack's `founding` block → whether
|
|
48
|
+
// this project was born through the startup flow, and its demo time box if a demo. A
|
|
49
|
+
// malformed demo value reads as no demo (the project still founds, with the normal plan).
|
|
50
|
+
function birthMarker(founding) {
|
|
51
|
+
const f = founding && typeof founding === 'object' ? founding : {};
|
|
52
|
+
const born = typeof f.birth === 'string' && Number.isFinite(new Date(f.birth).getTime());
|
|
53
|
+
if (!born) return { born: false, demo: null };
|
|
54
|
+
const people = Number(f.demoPeople);
|
|
55
|
+
const hours = Number(f.demoHours);
|
|
56
|
+
const demo = intIn(people, DEMO_PEOPLE[0], DEMO_PEOPLE[1]) && intIn(hours, DEMO_HOURS[0], DEMO_HOURS[1]) ? { people, hours_each: hours } : null;
|
|
57
|
+
return { born: true, demo };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function markerFrom(deps) {
|
|
61
|
+
if (deps.marker) return deps.marker;
|
|
62
|
+
let b;
|
|
63
|
+
try { b = require('../../src/module-api').branding(); } catch (_) { return { born: false, demo: null }; }
|
|
64
|
+
return birthMarker(b && b.founding);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// bornFounding(deps) — the guarded birth. Returns what it did:
|
|
68
|
+
// { ran: false, reason } nothing to do (no marker, already, alive)
|
|
69
|
+
// { ran: true, started, plan, demo } it started founding and/or saved the plan
|
|
70
|
+
// { ran: false, reason, retry: true } the demo plan could not be saved yet
|
|
71
|
+
async function bornFounding(deps = {}) {
|
|
72
|
+
const marker = markerFrom(deps);
|
|
73
|
+
if (!marker.born) return { ran: false, reason: 'no_marker' };
|
|
74
|
+
const get = deps.getSetting || require('../../src/module-api').projectSettings.get;
|
|
75
|
+
const [started, ended] = await Promise.all([get(fm.KEYS.STARTED_AT), get(fm.KEYS.ENDED_AT)]);
|
|
76
|
+
if (ended && ended.value) return { ran: false, reason: 'already_alive' };
|
|
77
|
+
let didStart = false;
|
|
78
|
+
if (!(started && started.value)) {
|
|
79
|
+
const r = await genesis.startFounding(null, deps);
|
|
80
|
+
if (!r.ok) return { ran: false, reason: r.reason };
|
|
81
|
+
didStart = true;
|
|
82
|
+
}
|
|
83
|
+
if (!marker.demo) return didStart ? { ran: true, started: true, plan: false, demo: false } : { ran: false, reason: 'already_started' };
|
|
84
|
+
// The demo's expedited plan: only while still deciding, and only if none is saved.
|
|
85
|
+
const mode = await fm.foundingModeFor(genesis.modeDepsFor({ ...deps, getSetting: get }));
|
|
86
|
+
const planRow = await get(genesis.PLAN_KEY);
|
|
87
|
+
if (!mode || mode.stage !== 'decide' || (planRow && planRow.value)) {
|
|
88
|
+
return didStart ? { ran: true, started: true, plan: false, demo: true } : { ran: false, reason: 'already_started' };
|
|
89
|
+
}
|
|
90
|
+
const saved = await genesis.savePlan(genesis.demoPlan(marker.demo), null, deps);
|
|
91
|
+
if (!saved.ok) return { ran: false, reason: saved.reason, retry: true };
|
|
92
|
+
return { ran: true, started: didStart, plan: true, demo: true };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ensureBirth(deps) — the once-per-process door the reads call. Shares one run between
|
|
96
|
+
// concurrent callers; forgets a run that failed or asked to retry, so a later read tries
|
|
97
|
+
// again. Never throws: a birth hiccup must never fail the read that triggered it.
|
|
98
|
+
let once = null;
|
|
99
|
+
function ensureBirth(deps = {}) {
|
|
100
|
+
if (once) return once;
|
|
101
|
+
const p = bornFounding(deps).then((r) => {
|
|
102
|
+
if (r && r.retry) once = null;
|
|
103
|
+
return r;
|
|
104
|
+
}, (err) => {
|
|
105
|
+
once = null;
|
|
106
|
+
try { require('../../src/module-api').logger('onboarding').error('[onboarding] founding birth failed', err); } catch (_) { /* no logger: nothing to do */ }
|
|
107
|
+
return { ran: false, reason: 'error' };
|
|
108
|
+
});
|
|
109
|
+
once = p;
|
|
110
|
+
return p;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Test seam: forget the per-process run.
|
|
114
|
+
function resetBirthForTests() { once = null; }
|
|
115
|
+
|
|
116
|
+
module.exports = { birthMarker, bornFounding, ensureBirth, resetBirthForTests };
|
|
@@ -75,7 +75,65 @@ function parsePlan(body = {}) {
|
|
|
75
75
|
if (government !== null && !/^[a-z][a-z-]{1,39}$/.test(government)) return { ok: false, reason: 'bad_government' };
|
|
76
76
|
const expected = b.expected_builders == null || b.expected_builders === '' ? null : Number(b.expected_builders);
|
|
77
77
|
if (expected !== null && !intIn(expected, 1, 100000)) return { ok: false, reason: 'bad_expected_builders' };
|
|
78
|
-
|
|
78
|
+
const plan = { days, passes, advisors, government, expected_builders: expected, existing: b.existing === true };
|
|
79
|
+
// A DEMO's plan (task 1004506) also carries its time box and each stage's length in
|
|
80
|
+
// minutes. Only the birth path writes these (demoPlan below): the plan route's schema
|
|
81
|
+
// does not name them, so a client cannot send one.
|
|
82
|
+
if (b.demo != null) {
|
|
83
|
+
const d = b.demo;
|
|
84
|
+
if (!d || !intIn(d.people, DEMO_PEOPLE[0], DEMO_PEOPLE[1]) || !intIn(d.hours_each, DEMO_HOURS[0], DEMO_HOURS[1])) return { ok: false, reason: 'bad_demo' };
|
|
85
|
+
plan.demo = { people: d.people, hours_each: d.hours_each };
|
|
86
|
+
}
|
|
87
|
+
if (b.minutes != null) {
|
|
88
|
+
plan.minutes = {};
|
|
89
|
+
for (const s of PROPER) {
|
|
90
|
+
const m = b.minutes[s];
|
|
91
|
+
if (!intIn(m, 1, MAX_DEMO_MINUTES)) return { ok: false, reason: 'bad_minutes', stage: s };
|
|
92
|
+
plan.minutes[s] = m;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return { ok: true, plan };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ── the demo's expedited genesis (task 1004506) ──────────────────────────────
|
|
99
|
+
// Owner ruling, 2026-10-01: a demo DOES go through genesis, but expedited, because the
|
|
100
|
+
// point of a demo is that the whole thing is fast. So a demo's plan is settled before
|
|
101
|
+
// the founder ever sees it, from what the demo already picked: Monarchy / BDFL (the
|
|
102
|
+
// founder's one yes closes each stage, spec D4's fast-track), one pass, no advisors,
|
|
103
|
+
// the demo's people as the headcount. Its stages are minutes, not days: genesis gets a
|
|
104
|
+
// fifth of the hours each person has (at least ten minutes, at most two hours), split
|
|
105
|
+
// by how much each stage asks, so a small team reaches "come alive" with most of the
|
|
106
|
+
// time box left for building.
|
|
107
|
+
const DEMO_PEOPLE = Object.freeze([1, 12]); // modules/provisioning/demo.js DEMO_LIMITS
|
|
108
|
+
const DEMO_HOURS = Object.freeze([1, 40]);
|
|
109
|
+
const MAX_DEMO_MINUTES = 240;
|
|
110
|
+
const DEMO_SHARE = 0.2;
|
|
111
|
+
const DEMO_GENESIS_MINUTES = Object.freeze([10, 120]);
|
|
112
|
+
const DEMO_WEIGHTS = Object.freeze({ scope: 3, strategize: 2, charter: 2, legalize: 1, legislation: 2 });
|
|
113
|
+
|
|
114
|
+
// demoMinutes(hoursEach) — PURE. Each stage's length in minutes, from the time box.
|
|
115
|
+
function demoMinutes(hoursEach) {
|
|
116
|
+
const [lo, hi] = DEMO_GENESIS_MINUTES;
|
|
117
|
+
const budget = Math.min(hi, Math.max(lo, Math.round(hoursEach * 60 * DEMO_SHARE)));
|
|
118
|
+
const total = Object.values(DEMO_WEIGHTS).reduce((a, b) => a + b, 0);
|
|
119
|
+
const out = {};
|
|
120
|
+
for (const s of PROPER) out[s] = Math.max(1, Math.round((budget * DEMO_WEIGHTS[s]) / total));
|
|
121
|
+
return out;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// demoPlan({ people, hours_each }) — PURE. The plan body a demo is born with; it goes
|
|
125
|
+
// through parsePlan like any other, so it is held to the same ranges.
|
|
126
|
+
function demoPlan({ people, hours_each: hoursEach }) {
|
|
127
|
+
return {
|
|
128
|
+
days: Object.fromEntries(PROPER.map((s) => [s, 1])),
|
|
129
|
+
minutes: demoMinutes(hoursEach),
|
|
130
|
+
passes: 1,
|
|
131
|
+
advisors: 'none',
|
|
132
|
+
government: 'monarchy',
|
|
133
|
+
expected_builders: people,
|
|
134
|
+
existing: false,
|
|
135
|
+
demo: { people, hours_each: hoursEach },
|
|
136
|
+
};
|
|
79
137
|
}
|
|
80
138
|
|
|
81
139
|
function readPlanValue(raw) {
|
|
@@ -99,8 +157,11 @@ function shapeGenesis({ mode, plan, tasks = {}, board = {}, stageAt = {} }) {
|
|
|
99
157
|
const list = (tasks[key] || []).map((t) => ({ ...t, finished: FINISHED.includes(t.status) }));
|
|
100
158
|
const b = board[key] || null;
|
|
101
159
|
const days = plan && plan.days && plan.days[key] ? plan.days[key] : info.days;
|
|
160
|
+
// A demo's stage is minutes long (task 1004506); its due time is counted in them.
|
|
161
|
+
const minutes = plan && plan.minutes && plan.minutes[key] ? plan.minutes[key] : null;
|
|
102
162
|
const began = stageAt[key] ? new Date(stageAt[key]) : null;
|
|
103
|
-
const
|
|
163
|
+
const span = minutes ? minutes * 60000 : days * 86400000;
|
|
164
|
+
const due = began && Number.isFinite(began.getTime()) ? new Date(began.getTime() + span).toISOString() : null;
|
|
104
165
|
return {
|
|
105
166
|
key,
|
|
106
167
|
number: idx,
|
|
@@ -110,6 +171,7 @@ function shapeGenesis({ mode, plan, tasks = {}, board = {}, stageAt = {} }) {
|
|
|
110
171
|
done_when: info.done_when,
|
|
111
172
|
wakes: fm.WAKES[key] || [],
|
|
112
173
|
days,
|
|
174
|
+
minutes,
|
|
113
175
|
due,
|
|
114
176
|
state: idx < at ? 'done' : idx === at ? 'now' : 'later',
|
|
115
177
|
tasks: list,
|
|
@@ -131,6 +193,8 @@ function shapeGenesis({ mode, plan, tasks = {}, board = {}, stageAt = {} }) {
|
|
|
131
193
|
day: mode.day,
|
|
132
194
|
deciding: mode.stage === 'decide',
|
|
133
195
|
plan: plan || null,
|
|
196
|
+
// The demo's time box when this genesis is a demo's (task 1004506), else null.
|
|
197
|
+
demo: plan && plan.demo ? plan.demo : null,
|
|
134
198
|
stages,
|
|
135
199
|
// The close door: every task of the current stage finished, and not already sitting.
|
|
136
200
|
can_close: !!(now && now.total > 0 && now.finished === now.total && !sitting && !passed),
|
|
@@ -193,8 +257,9 @@ async function readGenesis(deps = {}) {
|
|
|
193
257
|
return shapeGenesis({ mode, plan, tasks, board, stageAt });
|
|
194
258
|
}
|
|
195
259
|
|
|
196
|
-
// startFounding — a brand-new project enters genesis (
|
|
197
|
-
//
|
|
260
|
+
// startFounding — a brand-new project enters genesis (founding-birth.js calls this once
|
|
261
|
+
// on a project born through the startup flow, task 1004506). Refused once the project
|
|
262
|
+
// has come alive: genesis happens once (spec D8).
|
|
198
263
|
async function startFounding(builderId, deps = {}) {
|
|
199
264
|
const s = seams(deps);
|
|
200
265
|
const ended = await s.get(fm.KEYS.ENDED_AT);
|
|
@@ -269,6 +334,8 @@ async function onStagePassed(deps = {}) {
|
|
|
269
334
|
}
|
|
270
335
|
|
|
271
336
|
module.exports = {
|
|
272
|
-
PLAN_KEY, STAGE_INFO, PROPER, parsePlan, shapeGenesis,
|
|
337
|
+
PLAN_KEY, STAGE_INFO, PROPER, DEMO_PEOPLE, DEMO_HOURS, intIn, parsePlan, shapeGenesis, demoMinutes, demoPlan,
|
|
273
338
|
readGenesis, startFounding, savePlan, closeStage, comeAlive, onStagePassed,
|
|
339
|
+
// the founding-mode deps this file reads the mode with, for founding-birth.js (task 1004506)
|
|
340
|
+
modeDepsFor: (deps) => modeDeps(seams(deps)),
|
|
274
341
|
};
|
|
@@ -41,6 +41,7 @@
|
|
|
41
41
|
const onboardingState = require('./onboarding-state');
|
|
42
42
|
const newcomerRestock = require('./newcomer-restock');
|
|
43
43
|
const foundingMode = require('./founding-mode');
|
|
44
|
+
const foundingBirth = require('./founding-birth');
|
|
44
45
|
|
|
45
46
|
module.exports = {
|
|
46
47
|
// --- the newcomer onboarding state machine (onboarding-state.js) ---------
|
|
@@ -56,8 +57,14 @@ module.exports = {
|
|
|
56
57
|
|
|
57
58
|
// --- project founding mode (founding-mode.js, task 1004422) ---------------
|
|
58
59
|
// me.js reads it for the hall shell (null = not founding = the hall as today);
|
|
59
|
-
// the genesis home (task 1004423) drives the write.
|
|
60
|
-
|
|
60
|
+
// the genesis home (task 1004423) drives the write. A project born through the
|
|
61
|
+
// startup flow enters genesis on this, its first read (founding-birth.js, task
|
|
62
|
+
// 1004506): once, guarded, and a no-op without the birth marker. A caller passing
|
|
63
|
+
// its own deps (a test) skips the birth.
|
|
64
|
+
foundingModeFor: async (...a) => {
|
|
65
|
+
if (!a.length) await foundingBirth.ensureBirth();
|
|
66
|
+
return foundingMode.foundingModeFor(...a);
|
|
67
|
+
},
|
|
61
68
|
setFoundingMode: (...a) => foundingMode.setFoundingMode(...a),
|
|
62
69
|
|
|
63
70
|
// --- the evergreen newcomer-chore restock (newcomer-restock.js) ----------
|
|
@@ -36,6 +36,7 @@ const onboardingPort = require('../port');
|
|
|
36
36
|
const buildAccessRequestsRouter = require('./access-requests');
|
|
37
37
|
const foundingBuilders = require('../founding-builders');
|
|
38
38
|
const genesis = require('../genesis-home');
|
|
39
|
+
const foundingBirth = require('../founding-birth');
|
|
39
40
|
|
|
40
41
|
// Register the `onboarding` kernel PORT. See the header + port.js for the full
|
|
41
42
|
// consumer list and the graceful-degradation contract.
|
|
@@ -76,6 +77,7 @@ const GENESIS_REFUSALS = {
|
|
|
76
77
|
not_at_legislation: 409, legislation_not_passed: 409, stage_tasks_unfinished: 409,
|
|
77
78
|
no_stage_tasks: 409, unknown_stage: 400, no_board: 503,
|
|
78
79
|
bad_days: 400, bad_passes: 400, bad_advisors: 400, bad_government: 400, bad_expected_builders: 400,
|
|
80
|
+
bad_demo: 400, bad_minutes: 400,
|
|
79
81
|
};
|
|
80
82
|
let genesisListening = false;
|
|
81
83
|
|
|
@@ -101,7 +103,12 @@ function mountGenesisRoutes(router) {
|
|
|
101
103
|
res.status(500).json({ error: { code: 'genesis_failed', message: 'internal error' } });
|
|
102
104
|
}
|
|
103
105
|
};
|
|
104
|
-
|
|
106
|
+
// A project born through the startup flow enters genesis on its first founding read
|
|
107
|
+
// (founding-birth.js, task 1004506) — here and on GET /me through the port.
|
|
108
|
+
router.get('/founding/genesis', api.requireBuilder, handle(async (req) => {
|
|
109
|
+
await foundingBirth.ensureBirth();
|
|
110
|
+
return { genesis: await genesis.readGenesis(), viewer_id: String(req.builder.id) };
|
|
111
|
+
}));
|
|
105
112
|
router.post('/founding/start', api.requireBuilder, owns, handle((req) => genesis.startFounding(req.builder.id)));
|
|
106
113
|
// The plan's ranges and vocabularies are genesis-home.js parsePlan's (one source);
|
|
107
114
|
// this schema only names the fields so an unknown one is refused.
|