@bongos/core 1.25.15 → 1.25.17
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 +128 -58
- package/.claude/skills/goal-create/SKILL.md +1 -0
- package/.claude/skills/planning-session/SKILL.md +5 -4
- package/bin/bongos.js +1 -0
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +14 -0
- package/clients/bongos-client/index.cjs +14 -0
- package/clients/bongos-client/index.d.ts +22 -2
- package/clients/bongos-client/index.mjs +14 -0
- package/docs/adr/0406-every-project-gets-a-sandbox-that-follows-main-and-live-moves-only-when-a-person-promotes.md +8 -0
- package/docs/adr/0408-a-genesis-stage-is-three-doors-onto-one-record.md +1 -0
- package/docs/api/openapi.json +415 -3
- package/docs/api-reference.md +13 -2
- package/docs/copy-inventory.md +120 -109
- package/docs/copy-registry.json +275 -165
- package/docs/module-api-changelog.md +4 -2
- package/docs/page-inventory.json +2 -0
- package/docs/page-readings.json +107 -107
- package/docs/project-context.template.md +1 -0
- package/modules/government/decision-rules.js +1 -0
- package/modules/hall-ui/public/dom-utils.js +5 -0
- package/modules/hall-ui/public/genesis-home.js +23 -8
- package/modules/hall-ui/public/setup-gate.js +1 -1
- package/modules/hall-ui/public/style.css +21 -1
- package/modules/lifecycle/first-read-db.js +152 -0
- package/modules/lifecycle/first-read.js +265 -0
- package/modules/lifecycle/kickoff-checklist.js +29 -6
- package/modules/lifecycle/kickoff-seed.js +27 -4
- package/modules/lifecycle/migrations/lifecycle_022_repo_maps.sql +44 -0
- package/modules/lifecycle/module.json +2 -1
- package/modules/lifecycle/routes/first-read.js +79 -0
- package/modules/onboarding/genesis-home.js +11 -2
- package/modules/onboarding/genesis-work.js +28 -5
- package/modules/provisioning/pollers/app-liveness.js +23 -1
- package/modules/provisioning/render-sandbox.js +113 -0
- package/modules/provisioning/render-standup.js +3 -1
- package/modules/provisioning/routes/provisioning.js +4 -1
- package/modules/provisioning/routes/render-standup.js +49 -1
- package/modules/public-landing/public/assets/guide-scene.css +245 -0
- package/modules/public-landing/public/assets/guide-scene.js +566 -0
- package/modules/public-landing/public/index.html +62 -2
- package/modules/public-landing/public/projects.html +101 -4
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +23 -1
- package/scripts/bongos/first-read.js +116 -0
- package/scripts/bongos/genesis.js +2 -0
- package/scripts/bongos/government-walk.js +4 -4
- package/scripts/bongos/provision-render-sandbox.js +171 -0
- package/scripts/bongos/provision-render.js +37 -2
- package/scripts/bongos/provision-teardown.js +1 -1
- package/scripts/bongos/render-api.js +33 -3
- package/scripts/bongos/render-payload.js +39 -1
- package/scripts/gds/first-read.js +7 -0
- package/scripts/gds/provision-render-sandbox.js +6 -0
- package/src/module-api.js +1 -1
- package/tests/first_read.mjs +303 -0
- package/tests/genesis_home.mjs +70 -2
- package/tests/genesis_work.mjs +49 -0
- package/tests/government_act_alone_buildings.mjs +2 -0
- package/tests/landing_guide_scene.mjs +504 -0
- package/tests/provisioning_app_status.mjs +3 -2
- package/tests/provisioning_render_route.mjs +1 -1
- package/tests/provisioning_teardown_intent.mjs +50 -0
- package/tests/render_api.mjs +5 -2
- package/tests/render_sandbox.mjs +447 -0
|
@@ -85,6 +85,7 @@ async function seedGenesisStages({ builderId = null, existing = false } = {}, de
|
|
|
85
85
|
const pool = deps.pool || api.pool;
|
|
86
86
|
const insert = deps.createTask || ((row) => db.createTask(row));
|
|
87
87
|
const find = deps.findTaskBySourceRef || ((q) => db.findTaskBySourceRef(q));
|
|
88
|
+
const addDep = deps.addTaskDependency || ((id, dep) => db.addTaskDependency(id, dep));
|
|
88
89
|
const { rows: versions } = await pool.query(FIRST_VERSION_SQL);
|
|
89
90
|
if (!versions.length) return { seeded: 0, skipped: 'no_version' };
|
|
90
91
|
const versionId = versions[0].id;
|
|
@@ -109,18 +110,39 @@ async function seedGenesisStages({ builderId = null, existing = false } = {}, de
|
|
|
109
110
|
seeded += 1;
|
|
110
111
|
}
|
|
111
112
|
}
|
|
113
|
+
// task 1005110: the order genesisChecklist declares becomes a real edge, so the claim
|
|
114
|
+
// gate refuses the later step (DEPS_NOT_SHIPPED) until the earlier one ships. Written
|
|
115
|
+
// for steps already filed too (ON CONFLICT DO NOTHING), so a re-run repairs a missing edge.
|
|
116
|
+
for (const steps of Object.values(genesisChecklist({ existing }))) {
|
|
117
|
+
for (const t of steps) {
|
|
118
|
+
if (!t.after) continue;
|
|
119
|
+
const [me, before] = await Promise.all([
|
|
120
|
+
find({ source: GENESIS_SOURCE, sourceRef: t.ref }),
|
|
121
|
+
find({ source: GENESIS_SOURCE, sourceRef: t.after }),
|
|
122
|
+
]);
|
|
123
|
+
if (me && before) await addDep(me.id, before.id);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
112
126
|
return { seeded, versionId };
|
|
113
127
|
}
|
|
114
128
|
|
|
115
|
-
// genesisStageTasks(deps) → { stageKey: [{ id, title, status, description }] } for
|
|
116
|
-
// genesis task on the board, grouped by the stage its source_ref names. The genesis
|
|
117
|
-
// lists them, and a stage close hands their ids to the Board Room's finished-tasks gate.
|
|
129
|
+
// genesisStageTasks(deps) → { stageKey: [{ id, title, status, description, waits_on }] } for
|
|
130
|
+
// every genesis task on the board, grouped by the stage its source_ref names. The genesis
|
|
131
|
+
// home lists them, and a stage close hands their ids to the Board Room's finished-tasks gate.
|
|
132
|
+
// `waits_on` is [{ id, title }] of the tasks it depends on that have not shipped: the claim
|
|
133
|
+
// gate's own rule (unsatisfiedDeps, abandoned still blocks), so a row drawn locked is one
|
|
134
|
+
// POST /claims would refuse (task 1005110).
|
|
118
135
|
// The description rides along so each row's Copy prompt carries the task's own words
|
|
119
136
|
// (task 1005109); the read stays bounded, it is only the few seeded genesis tasks.
|
|
120
137
|
async function genesisStageTasks(deps = {}) {
|
|
121
138
|
const pool = deps.pool || api.pool;
|
|
122
139
|
const { rows } = await pool.query(
|
|
123
|
-
`SELECT id, title, status, description, source_ref
|
|
140
|
+
`SELECT t.id, t.title, t.status, t.description, t.source_ref,
|
|
141
|
+
COALESCE((SELECT json_agg(json_build_object('id', x.id, 'title', x.title) ORDER BY x.id)
|
|
142
|
+
FROM dependencies d JOIN tasks x ON x.id = d.to_id
|
|
143
|
+
WHERE d.from_kind = 'task' AND d.from_id = t.id
|
|
144
|
+
AND d.to_kind = 'task' AND x.status <> 'shipped'), '[]'::json) AS waits_on
|
|
145
|
+
FROM tasks t WHERE t.source = $1 ORDER BY t.id ASC`,
|
|
124
146
|
[GENESIS_SOURCE],
|
|
125
147
|
);
|
|
126
148
|
const out = {};
|
|
@@ -129,6 +151,7 @@ async function genesisStageTasks(deps = {}) {
|
|
|
129
151
|
if (!stage) continue;
|
|
130
152
|
(out[stage] = out[stage] || []).push({
|
|
131
153
|
id: String(r.id), title: r.title, status: r.status, description: r.description || '',
|
|
154
|
+
waits_on: (Array.isArray(r.waits_on) ? r.waits_on : []).map((w) => ({ id: String(w.id), title: w.title })),
|
|
132
155
|
});
|
|
133
156
|
}
|
|
134
157
|
return out;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
-- lifecycle_022_repo_maps.sql — the first read-through's repository maps (task 1005280,
|
|
2
|
+
-- goal 1000106).
|
|
3
|
+
--
|
|
4
|
+
-- WHAT IT IS. A project that arrives with code gets a prompt for the owner's OWN AI
|
|
5
|
+
-- session; that session reads the repository on the owner's computer and subscription
|
|
6
|
+
-- and sends back a map (modules/lifecycle/first-read.js). A map waits here as `pending`
|
|
7
|
+
-- until the owner accepts it — whole or item by item — or rejects it. Nothing is filed
|
|
8
|
+
-- by the submit itself.
|
|
9
|
+
--
|
|
10
|
+
-- WHY A SECOND TABLE. Re-running the read-through must not file the same goal or task
|
|
11
|
+
-- twice, and goals carry no source_ref to dedup on. So every accepted item's stable
|
|
12
|
+
-- `ref` (derived from its title, never its position) is a row in
|
|
13
|
+
-- lifecycle_repo_map_filed, PRIMARY KEY on the ref: the next accept skips it, whichever
|
|
14
|
+
-- map names it again.
|
|
15
|
+
--
|
|
16
|
+
-- MODULE-OWNED and `lifecycle_`-prefixed (ADR 0083 §Decision #5).
|
|
17
|
+
|
|
18
|
+
CREATE TABLE IF NOT EXISTS lifecycle_repo_maps (
|
|
19
|
+
id bigserial PRIMARY KEY,
|
|
20
|
+
schema text NOT NULL,
|
|
21
|
+
map jsonb NOT NULL,
|
|
22
|
+
status text NOT NULL DEFAULT 'pending',
|
|
23
|
+
submitted_by bigint REFERENCES builders(id),
|
|
24
|
+
submitted_at timestamptz NOT NULL DEFAULT now(),
|
|
25
|
+
reviewed_by bigint REFERENCES builders(id),
|
|
26
|
+
reviewed_at timestamptz,
|
|
27
|
+
|
|
28
|
+
CONSTRAINT lifecycle_repo_maps_status_chk
|
|
29
|
+
CHECK (status IN ('pending', 'accepted', 'rejected'))
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
CREATE INDEX IF NOT EXISTS lifecycle_repo_maps_newest_idx
|
|
33
|
+
ON lifecycle_repo_maps (submitted_at DESC);
|
|
34
|
+
|
|
35
|
+
CREATE TABLE IF NOT EXISTS lifecycle_repo_map_filed (
|
|
36
|
+
ref text PRIMARY KEY,
|
|
37
|
+
kind text NOT NULL,
|
|
38
|
+
entity_id bigint NOT NULL,
|
|
39
|
+
map_id bigint NOT NULL REFERENCES lifecycle_repo_maps(id),
|
|
40
|
+
filed_by bigint REFERENCES builders(id),
|
|
41
|
+
filed_at timestamptz NOT NULL DEFAULT now(),
|
|
42
|
+
|
|
43
|
+
CONSTRAINT lifecycle_repo_map_filed_kind_chk CHECK (kind IN ('goal', 'task'))
|
|
44
|
+
);
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// modules/lifecycle/routes/first-read.js — the HTTP surface of the first read-through
|
|
2
|
+
// (task 1005280, goal 1000106): hand out the prompt, take a repository map from the
|
|
3
|
+
// owner's own AI session, show it for review, and accept or reject it.
|
|
4
|
+
//
|
|
5
|
+
// No route here calls an AI, reads a repository, or files anything on submit. Filing is
|
|
6
|
+
// the accept route alone, gated like creating a goal (`goal.create` + the planning
|
|
7
|
+
// act-alone rule), because accepting a map IS creating goals and tasks. The rules are
|
|
8
|
+
// first-read.js; the store is first-read-db.js. Reaches core only through the doorway.
|
|
9
|
+
|
|
10
|
+
'use strict';
|
|
11
|
+
|
|
12
|
+
const express = require('express');
|
|
13
|
+
const api = require('../../../src/module-api');
|
|
14
|
+
const fr = require('../first-read');
|
|
15
|
+
const store = require('../first-read-db');
|
|
16
|
+
|
|
17
|
+
const { asyncHandler, parseId, validateOrRespond } = api;
|
|
18
|
+
|
|
19
|
+
module.exports = function buildFirstReadRouter() {
|
|
20
|
+
const router = express.Router();
|
|
21
|
+
|
|
22
|
+
// rank: any-builder — the prompt and the shape are instructions, not project data.
|
|
23
|
+
router.get('/first-read/prompt', api.requireBuilder, (req, res) => {
|
|
24
|
+
res.json({ schema: fr.SCHEMA, file: fr.MAP_FILE, prompt: fr.firstReadPrompt(), example: fr.exampleMap(), limits: fr.LIMITS });
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
// rank: goal.create — a map is a planning proposal for this project. Stores it
|
|
28
|
+
// pending; files nothing. A malformed map is refused with every error named.
|
|
29
|
+
router.post('/first-read/maps', api.requireBuilder, api.requirePermission('goal.create'), asyncHandler('POST /first-read/maps', async (req, res) => {
|
|
30
|
+
// The envelope is checked here; the map inside it by first-read.js, which names every error.
|
|
31
|
+
if (validateOrRespond(req, res, { map: { required: true, type: 'object' } })) return undefined;
|
|
32
|
+
const out = await store.submitMap({ raw: req.body.map, builderId: req.builder.id });
|
|
33
|
+
if (!out.ok) return res.fail('invalid_map', 400, { errors: out.errors });
|
|
34
|
+
return res.status(201).json({
|
|
35
|
+
map_id: out.map_id, status: out.status,
|
|
36
|
+
items: out.items.map((i) => ({ ref: i.ref, type: i.type, title: i.title })),
|
|
37
|
+
next: 'Show the owner the map. File it only with their yes: POST /first-read/maps/' + out.map_id + '/accept { all: true } or { items: [ref] }.',
|
|
38
|
+
});
|
|
39
|
+
}, { errorCode: 'first_read_submit_failed' }));
|
|
40
|
+
|
|
41
|
+
// rank: goal.create — the review is for whoever may accept or reject it.
|
|
42
|
+
router.get('/first-read/maps/:id', api.requireBuilder, api.requirePermission('goal.create'), asyncHandler('GET /first-read/maps/:id', async (req, res) => {
|
|
43
|
+
const id = req.params.id === 'latest' ? 'latest' : parseId(req, res, { positiveInt: true });
|
|
44
|
+
if (id == null) return undefined;
|
|
45
|
+
const review = await store.reviewMap(id);
|
|
46
|
+
if (!review) return res.fail('not_found', 404);
|
|
47
|
+
return res.json(review);
|
|
48
|
+
}, { errorCode: 'first_read_review_failed' }));
|
|
49
|
+
|
|
50
|
+
// rank: goal.create + act-alone planning — the owner's yes. Files the chosen items as
|
|
51
|
+
// draft goals (open) and tasks (backlog); an item already filed is skipped.
|
|
52
|
+
router.post('/first-read/maps/:id/accept', api.requireBuilder, api.requirePermission('goal.create'), api.requireActAlone('planning'),
|
|
53
|
+
asyncHandler('POST /first-read/maps/:id/accept', async (req, res) => {
|
|
54
|
+
const mapId = parseId(req, res, { positiveInt: true });
|
|
55
|
+
if (mapId == null) return undefined;
|
|
56
|
+
if (validateOrRespond(req, res, {
|
|
57
|
+
all: { type: 'boolean' },
|
|
58
|
+
items: { type: 'array', itemsType: 'string', maxItems: 500 },
|
|
59
|
+
})) return undefined;
|
|
60
|
+
const { all, items } = req.body || {};
|
|
61
|
+
if ((all === true) === Array.isArray(items)) {
|
|
62
|
+
return res.fail('invalid_items', 400, { hint: 'send { all: true } or { items: [ref, …] } — the refs from the review' });
|
|
63
|
+
}
|
|
64
|
+
const out = await store.acceptMap({ mapId, select: all === true ? 'all' : items, builderId: req.builder.id });
|
|
65
|
+
if (!out.ok) { const { code, status, ...rest } = out; return res.fail(code, status, rest); }
|
|
66
|
+
return res.json(out);
|
|
67
|
+
}, { errorCode: 'first_read_accept_failed' }));
|
|
68
|
+
|
|
69
|
+
// rank: goal.create — the owner's no. Files nothing.
|
|
70
|
+
router.post('/first-read/maps/:id/reject', api.requireBuilder, api.requirePermission('goal.create'), asyncHandler('POST /first-read/maps/:id/reject', async (req, res) => {
|
|
71
|
+
const mapId = parseId(req, res, { positiveInt: true });
|
|
72
|
+
if (mapId == null) return undefined;
|
|
73
|
+
const out = await store.rejectMap({ mapId, builderId: req.builder.id });
|
|
74
|
+
if (!out.ok) { const { code, status, ...rest } = out; return res.fail(code, status, rest); }
|
|
75
|
+
return res.json(out);
|
|
76
|
+
}, { errorCode: 'first_read_reject_failed' }));
|
|
77
|
+
|
|
78
|
+
return router;
|
|
79
|
+
};
|
|
@@ -143,10 +143,19 @@ function readPlanValue(raw) {
|
|
|
143
143
|
|
|
144
144
|
const FINISHED = Object.freeze(['shipped', 'abandoned']); // the Board Room's own gate, mirrored for display
|
|
145
145
|
|
|
146
|
+
// taskState(t) — one stage task as the checklist draws it (task 1005110).
|
|
147
|
+
function taskState(t) {
|
|
148
|
+
const finished = FINISHED.includes(t.status);
|
|
149
|
+
const waits = finished ? [] : (Array.isArray(t.waits_on) ? t.waits_on : []);
|
|
150
|
+
return { ...t, finished, waits_on: waits, state: finished ? 'done' : waits.length ? 'locked' : 'open' };
|
|
151
|
+
}
|
|
152
|
+
|
|
146
153
|
// shapeGenesis({ mode, plan, tasks, board, stageAt }) — PURE. The genesis home's view.
|
|
147
154
|
// mode foundingModeFor's shape (non-null)
|
|
148
155
|
// plan the saved plan or null (deciding)
|
|
149
|
-
// tasks { stage: [{ id, title, status, description }] }
|
|
156
|
+
// tasks { stage: [{ id, title, status, description, waits_on }] }
|
|
157
|
+
// Each task comes back with `state`: 'done' (finished), 'locked' (waits on an unshipped
|
|
158
|
+
// task, named in `waits_on`) or 'open' — decided here, never re-derived in a browser.
|
|
150
159
|
// board { stage: genesisStageStatus row } or {}
|
|
151
160
|
// stageAt { stage: ISO } — when each stage began, for its due date
|
|
152
161
|
function shapeGenesis({ mode, plan, tasks = {}, board = {}, stageAt = {} }) {
|
|
@@ -154,7 +163,7 @@ function shapeGenesis({ mode, plan, tasks = {}, board = {}, stageAt = {} }) {
|
|
|
154
163
|
const stages = PROPER.map((key) => {
|
|
155
164
|
const info = STAGE_INFO[key];
|
|
156
165
|
const idx = fm.STAGES.indexOf(key);
|
|
157
|
-
const list = (tasks[key] || []).map((t) => (
|
|
166
|
+
const list = (tasks[key] || []).map((t) => taskState(t));
|
|
158
167
|
const b = board[key] || null;
|
|
159
168
|
const allFinished = list.length > 0 && list.every((t) => t.finished);
|
|
160
169
|
const onBoard = !!(b && (b.state === 'sitting' || b.state === 'passed'));
|
|
@@ -40,7 +40,7 @@
|
|
|
40
40
|
// un-answer appends a history row, so the founder can see what an answer used to say.
|
|
41
41
|
//
|
|
42
42
|
// WHICH STAGES. A stage adopts the pattern by declaring its questions in QUESTIONS.
|
|
43
|
-
// Strategize does
|
|
43
|
+
// Strategize does (task 1005116); scope's guided door is its project interview (task 1005114);
|
|
44
44
|
// charter, legalize and legislation adopt it when they are next touched. A stage with
|
|
45
45
|
// no questions still takes a free page and a link.
|
|
46
46
|
//
|
|
@@ -59,8 +59,30 @@ const LIMITS = Object.freeze({ answer: 4000, page: 20000, link: 500 });
|
|
|
59
59
|
|
|
60
60
|
// Each adopted stage's questions, in the order the guided door asks them. `id` is
|
|
61
61
|
// stable: a stored answer names it, so never rename or reuse one. `hint` is the shape
|
|
62
|
-
// of a good answer, shown under the question in every door.
|
|
62
|
+
// of a good answer, shown under the question in every door. `group` (optional) is a
|
|
63
|
+
// heading the guided door shows above the questions that share it.
|
|
64
|
+
//
|
|
65
|
+
// SCOPE IS THE PROJECT INTERVIEW (task 1005114): the questions every later goal and task
|
|
66
|
+
// should be planned inside — what this is for, who it is for, and the bet — rather than
|
|
67
|
+
// V1's edges, which is what came back thin before. The owner's starter set, as written;
|
|
68
|
+
// the list is "not final" and the owner invited more. These are prose questions; the
|
|
69
|
+
// closed-vocabulary ones (team shape, geography, entity, rewards) are the hub's project
|
|
70
|
+
// detail and are not repeated here.
|
|
63
71
|
const QUESTIONS = Object.freeze({
|
|
72
|
+
scope: Object.freeze([
|
|
73
|
+
Object.freeze({ id: 'what_creating', group: 'What and why', ask: 'In a few sentences, what are you creating with this project?',
|
|
74
|
+
hint: 'Say it the way you would to a friend. What exists when this is done that does not exist now?' }),
|
|
75
|
+
Object.freeze({ id: 'goal_impact', group: 'What and why', ask: 'What is your goal with it? What impact do you want it to have?',
|
|
76
|
+
hint: 'Why this is worth doing at all, and for whom it changes something.' }),
|
|
77
|
+
Object.freeze({ id: 'success_two_years', group: 'What and why', ask: 'In two years, what does success look like?',
|
|
78
|
+
hint: 'Something you could point at: who is using it, what they do with it, what it has become.' }),
|
|
79
|
+
Object.freeze({ id: 'reach_who', group: 'Who', ask: 'Who do you want to reach with this project?',
|
|
80
|
+
hint: 'The people it is ultimately for, not only the first ones. Be as specific as you can.' }),
|
|
81
|
+
Object.freeze({ id: 'work_with_who', group: 'Who', ask: 'Who do you want working on it with you?',
|
|
82
|
+
hint: 'The kind of people you hope join: their skills, how they work, what they care about.' }),
|
|
83
|
+
Object.freeze({ id: 'others_wrong', group: 'The bet', ask: 'What is everyone else in this area getting wrong?',
|
|
84
|
+
hint: 'The thing you believe that others in this space do not. This is the bet the project makes.' }),
|
|
85
|
+
]),
|
|
64
86
|
strategize: Object.freeze([
|
|
65
87
|
Object.freeze({ id: 'big_pieces', ask: 'What are the big pieces, and in what order do they get built?',
|
|
66
88
|
hint: 'Three to six pieces is plenty. Say why that order: what has to exist before what.' }),
|
|
@@ -118,7 +140,7 @@ function shapeStage(stage, work, answers = []) {
|
|
|
118
140
|
const questions = questionsFor(stage).map((q) => {
|
|
119
141
|
const a = byId.get(q.id);
|
|
120
142
|
return {
|
|
121
|
-
id: q.id, ask: q.ask, hint: q.hint,
|
|
143
|
+
id: q.id, ask: q.ask, hint: q.hint, group: q.group || null,
|
|
122
144
|
answer: a ? {
|
|
123
145
|
state: a.state, text: a.answer_md || null, origin: a.origin,
|
|
124
146
|
// A carried-over answer is shown as such until the founder makes it theirs.
|
|
@@ -196,11 +218,12 @@ function assistPrompt(stage) {
|
|
|
196
218
|
'- Offer two or three options when I am stuck, never one confident answer.',
|
|
197
219
|
'- "Not sure yet" is a fine answer. Record it as that rather than filling the gap for me.',
|
|
198
220
|
'- Record an answer only after I say it is mine, in my words (you may tidy the wording; read it back first).',
|
|
221
|
+
'- Once I have answered, you may offer a draft page built only from my answers. I edit it; save it only when I say it is mine.',
|
|
199
222
|
'',
|
|
200
223
|
];
|
|
201
224
|
if (qs.length) {
|
|
202
|
-
lines.push('The questions:');
|
|
203
|
-
qs.forEach((q, i) => lines.push(`${i + 1}. ${q.ask} (${q.hint})`));
|
|
225
|
+
lines.push('The questions (the id in brackets is what you record the answer under):');
|
|
226
|
+
qs.forEach((q, i) => lines.push(`${i + 1}. [${q.id}] ${q.ask} (${q.hint})`));
|
|
204
227
|
lines.push('');
|
|
205
228
|
}
|
|
206
229
|
lines.push('To see what I have already said: npx bongos genesis show ' + stage);
|
|
@@ -86,7 +86,29 @@ async function sweepOnce(deps = {}) {
|
|
|
86
86
|
[r.id, h.state]);
|
|
87
87
|
if (rowCount) { live++; log(`[app-liveness] ${r.slug}: its app answers at ${h.serviceUrl} — live`); }
|
|
88
88
|
}
|
|
89
|
-
|
|
89
|
+
// The project's SANDBOX (ADR 0406, task 1005289) gets the same knock until its first
|
|
90
|
+
// answer, then never again: it sleeps on the free plan, and a knock would wake it.
|
|
91
|
+
const { rows: sbs } = await db.query(
|
|
92
|
+
`SELECT id, slug, app_host->'sandbox' AS sb FROM provisioning_instances
|
|
93
|
+
WHERE app_host->>'platform' = 'render' AND app_host->'sandbox'->>'state' = 'deploying'
|
|
94
|
+
AND status <> 'torn_down'
|
|
95
|
+
ORDER BY id LIMIT ${SWEEP_LIMIT}`);
|
|
96
|
+
// the same few-at-a-time knocking as the apps above, so one slow sandbox holds none up
|
|
97
|
+
const sbAnswers = [];
|
|
98
|
+
for (let i = 0; i < sbs.length; i += CONCURRENCY) {
|
|
99
|
+
const batch = sbs.slice(i, i + CONCURRENCY);
|
|
100
|
+
sbAnswers.push(...(await Promise.all(batch.map((r) => appAnswers((r.sb || {}).serviceUrl, { fetchImpl: deps.fetchImpl, timeoutMs: deps.timeoutMs })))));
|
|
101
|
+
}
|
|
102
|
+
for (const [i, r] of sbs.entries()) {
|
|
103
|
+
const sb = r.sb || {};
|
|
104
|
+
if (!sbAnswers[i]) continue;
|
|
105
|
+
const { rowCount } = await db.query(
|
|
106
|
+
`UPDATE provisioning_instances
|
|
107
|
+
SET app_host = jsonb_set(app_host, '{sandbox}', (app_host->'sandbox') || jsonb_build_object('state', 'live', 'liveAt', now()::text))
|
|
108
|
+
WHERE id = $1 AND app_host->'sandbox'->>'state' = 'deploying'`, [r.id]);
|
|
109
|
+
if (rowCount) { live++; log(`[app-liveness] ${r.slug}: its sandbox answers at ${sb.serviceUrl} — live`); }
|
|
110
|
+
}
|
|
111
|
+
return { checked: rows.length + sbs.length, live };
|
|
90
112
|
}
|
|
91
113
|
|
|
92
114
|
function start(deps = {}) {
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// modules/provisioning/render-sandbox.js — the web tier's half of a project's SANDBOX on
|
|
2
|
+
// Render (task 1005289; ADR 0406, ADR 0327).
|
|
3
|
+
//
|
|
4
|
+
// WHY. Every project whose app runs on Render gets a second, free app from the same
|
|
5
|
+
// repository that follows `main`, so finished work reaches a test copy first (ADR 0406 §2).
|
|
6
|
+
// A new connection asks for one by itself (render-standup.js appHostFrom sets
|
|
7
|
+
// `sandboxWanted`); an existing project adds one here with one action; the owner reads its
|
|
8
|
+
// address and state beside the app's, in the same shape (app-status.js); and a teardown
|
|
9
|
+
// removes it when the owner lends their key with it.
|
|
10
|
+
//
|
|
11
|
+
// Like the rest of this module the web tier never calls Render (ADR 0111 §2): it queues a
|
|
12
|
+
// `render-standup` intent carrying the encrypted key, and the runner
|
|
13
|
+
// (scripts/bongos/provision-render-sandbox.js) does the work and clears the key.
|
|
14
|
+
'use strict';
|
|
15
|
+
|
|
16
|
+
const { appStatus } = require('./app-status');
|
|
17
|
+
|
|
18
|
+
// The sandbox's own database starts EMPTY: the platform cannot know an app's tables, so
|
|
19
|
+
// the fake data is the app's own seed, run when it sees BONGOS_SANDBOX=1 (the starter's
|
|
20
|
+
// half, task 1005291). Said to the owner plainly rather than promised.
|
|
21
|
+
const READY_DB_NOTE = 'It has its own database, separate from your live data. It starts empty: your app fills it with test data when it runs as a sandbox.';
|
|
22
|
+
|
|
23
|
+
// Why a project cannot have a sandbox right now, in the owner's words, or null.
|
|
24
|
+
function noSandboxReason(inst) {
|
|
25
|
+
const h = (inst && inst.app_host) || {};
|
|
26
|
+
if (!inst || inst.status === 'torn_down' || inst.status === 'tearing_down') return 'This project has been removed, so it has no sandbox.';
|
|
27
|
+
if (!h.platform) return 'Your project has no app yet, so there is nothing to run in a sandbox. One is set up as soon as you connect your app to Render.';
|
|
28
|
+
if (h.platform !== 'render') return 'Sandboxes are set up on Render only, and your app runs somewhere else.';
|
|
29
|
+
if (!h.serviceId || !['deploying', 'linked', 'live'].includes(h.state)) return 'Your app is not set up on Render yet. Its sandbox is set up with it.';
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// The owner's view of the sandbox: app-status.js's shape (stage, address, links, a
|
|
34
|
+
// failure as one sentence) plus what its database is and whether one can be added.
|
|
35
|
+
// Read from the row alone.
|
|
36
|
+
function sandboxStatus(inst, { now = Date.now() } = {}) {
|
|
37
|
+
const h = (inst && inst.app_host) || {};
|
|
38
|
+
const sb = h.sandbox;
|
|
39
|
+
const live = !sb || sb.state === 'removed';
|
|
40
|
+
if (live && !h.sandboxWanted) {
|
|
41
|
+
const why = noSandboxReason(inst);
|
|
42
|
+
return { state: 'none', message: why || 'This project has no sandbox yet.', can_add: !why };
|
|
43
|
+
}
|
|
44
|
+
if (!sb || sb.state === 'requested') {
|
|
45
|
+
const why = h.platform === 'render' && h.state === 'failed' ? 'Your app’s setup on Render stopped, so its sandbox is waiting for it.' : null;
|
|
46
|
+
return { state: 'requested', stage: 'creating', message: why || 'Your sandbox is set up right after your app.', can_add: false };
|
|
47
|
+
}
|
|
48
|
+
if (sb.state === 'left') return { state: 'left', error: sb.error || 'The sandbox is still on your Render account.', can_add: false };
|
|
49
|
+
const s = appStatus({ platform: 'render', state: sb.state, serviceId: sb.serviceId, serviceUrl: sb.serviceUrl, updatedAt: sb.updatedAt, error: sb.error }, { now });
|
|
50
|
+
return {
|
|
51
|
+
...s,
|
|
52
|
+
retry: undefined,
|
|
53
|
+
plan: 'free',
|
|
54
|
+
follows: 'main',
|
|
55
|
+
database: { state: sb.database || 'none', note: sb.dbNote || (sb.database === 'ready' ? READY_DB_NOTE : null) },
|
|
56
|
+
can_add: sb.state === 'failed' && !noSandboxReason(inst),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Queue an existing project's sandbox: a render-standup intent holding the key, and the
|
|
61
|
+
// row marked to want one, in ONE statement — the enqueueRenderStandup pattern, so an
|
|
62
|
+
// open-intent collision keeps nothing. Returns { intent } or { conflict, intent }.
|
|
63
|
+
async function enqueueSandboxAdd(db, instanceId, { keyEnc, requestedBy }) {
|
|
64
|
+
await require('./control-plane-guard').assertIntentAllowed(db, instanceId, 'render-standup');
|
|
65
|
+
try {
|
|
66
|
+
const { rows } = await db.query(
|
|
67
|
+
`WITH i AS (
|
|
68
|
+
INSERT INTO provisioning_intents (instance_id, action, requested_by, state, borrowed_key_enc)
|
|
69
|
+
VALUES ($1, 'render-standup', $2, 'pending', $3) RETURNING *
|
|
70
|
+
), u AS (
|
|
71
|
+
UPDATE provisioning_instances
|
|
72
|
+
SET app_host = app_host || jsonb_build_object('sandboxWanted', true,
|
|
73
|
+
'sandbox', COALESCE(app_host->'sandbox', '{}'::jsonb) || jsonb_build_object('state', 'requested', 'error', NULL))
|
|
74
|
+
WHERE id = $1 RETURNING id
|
|
75
|
+
)
|
|
76
|
+
SELECT i.id, i.instance_id, i.action, i.state, i.created_at FROM i`,
|
|
77
|
+
[instanceId, requestedBy ?? null, keyEnc]);
|
|
78
|
+
return { intent: rows[0] };
|
|
79
|
+
} catch (e) {
|
|
80
|
+
if (e && e.code === '23505') {
|
|
81
|
+
const { rows } = await db.query(
|
|
82
|
+
`SELECT id, action, state FROM provisioning_intents
|
|
83
|
+
WHERE instance_id = $1 AND state IN ('pending', 'running') ORDER BY created_at DESC LIMIT 1`, [instanceId]);
|
|
84
|
+
return { conflict: 'open_intent', intent: rows[0] || { action: 'unknown', state: 'open' } };
|
|
85
|
+
}
|
|
86
|
+
throw e;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Put the owner's key on a teardown that is still pending, so the runner can remove the
|
|
91
|
+
// sandbox (and only the sandbox). The table's CHECK refuses a key on a resolved intent.
|
|
92
|
+
async function lendKeyToTeardown(db, intentId, keyEnc) {
|
|
93
|
+
const { rowCount } = await db.query(
|
|
94
|
+
`UPDATE provisioning_intents SET borrowed_key_enc = $2, updated_at = now()
|
|
95
|
+
WHERE id = $1 AND action = 'teardown' AND state = 'pending'`, [intentId, keyEnc]);
|
|
96
|
+
return rowCount > 0;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// The teardown route's half: the owner's Render key, sent as the `x-render-key` header
|
|
100
|
+
// (a header, not a body: the kernel's audit log keeps a copy of write bodies), lent to the
|
|
101
|
+
// teardown just queued so the runner can remove the sandbox. Returns whether it was lent.
|
|
102
|
+
// A missing or malformed key, a teardown that was not just created, or a platform that
|
|
103
|
+
// cannot seal a key safely each lend nothing, and the teardown goes ahead without it.
|
|
104
|
+
const KEY_RE = /^rnd_[A-Za-z0-9]{16,128}$/;
|
|
105
|
+
async function lendKeyFromRequest(db, req, teardown, secretBox) {
|
|
106
|
+
if (!teardown || !teardown.created || !teardown.intent) return false;
|
|
107
|
+
const key = String((req.headers && req.headers['x-render-key']) || '').trim();
|
|
108
|
+
if (!KEY_RE.test(key)) return false;
|
|
109
|
+
if (!secretBox || !secretBox.isConfigured()) return false;
|
|
110
|
+
return module.exports.lendKeyToTeardown(db, teardown.intent.id, secretBox.encrypt(key)); // through the export, so a test can stand in for the write
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
module.exports = { noSandboxReason, sandboxStatus, enqueueSandboxAdd, lendKeyToTeardown, lendKeyFromRequest };
|
|
@@ -84,7 +84,9 @@ function badRequest(body) {
|
|
|
84
84
|
|
|
85
85
|
// What the row records about the request — ids and plans, never the key.
|
|
86
86
|
function appHostFrom(body) {
|
|
87
|
-
|
|
87
|
+
// Every app on Render gets a sandbox beside it (ADR 0406, task 1005289): the runner sets
|
|
88
|
+
// it up after the app, with the same key (scripts/bongos/provision-render-sandbox.js).
|
|
89
|
+
const h = { platform: 'render', mode: body.mode, ownerId: body.owner_id, state: 'requested', error: null, sandboxWanted: true };
|
|
88
90
|
if (body.mode === 'adopt') h.serviceId = body.service_id;
|
|
89
91
|
if (body.mode === 'greenfield') {
|
|
90
92
|
h.runtime = body.runtime;
|
|
@@ -50,6 +50,7 @@
|
|
|
50
50
|
const crypto = require('node:crypto');
|
|
51
51
|
const express = require('express');
|
|
52
52
|
const api = require('../../../src/module-api');
|
|
53
|
+
const renderSandbox = require('../render-sandbox');
|
|
53
54
|
const auth = api, { pool, validateOrRespond, parseId } = api;
|
|
54
55
|
const provisioning = require('../provisioning');
|
|
55
56
|
const { costLedger, ownerMonthlyUsd } = require('../cost-ledger');
|
|
@@ -1002,6 +1003,8 @@ module.exports = function provisioningRoutes() {
|
|
|
1002
1003
|
message: `A '${r.intent.action}' run is currently ${r.intent.state} for this instance — the teardown could not be queued; retry in a moment.`,
|
|
1003
1004
|
details: { action: r.intent.action, state: r.intent.state } });
|
|
1004
1005
|
}
|
|
1006
|
+
// An optional Render key (x-render-key) lets the runner remove the project's sandbox — never the live app (ADR 0406 §7, task 1005289).
|
|
1007
|
+
const lent = await renderSandbox.lendKeyFromRequest(pool, req, r, api.secretBox);
|
|
1005
1008
|
if (r.created) {
|
|
1006
1009
|
const who = isProjectOwner(inst, req.builder) ? 'owner' : `archon ${req.builder.github_login || req.builder.id}`;
|
|
1007
1010
|
await provisioning.recordEvent(pool, {
|
|
@@ -1010,7 +1013,7 @@ module.exports = function provisioningRoutes() {
|
|
|
1010
1013
|
actor,
|
|
1011
1014
|
}).catch(() => {});
|
|
1012
1015
|
}
|
|
1013
|
-
res.json({ ok: true, queued: r.created, action: 'teardown', status: inst.status, superseded: r.superseded,
|
|
1016
|
+
res.json({ ok: true, queued: r.created, action: 'teardown', status: inst.status, superseded: r.superseded, sandbox_key_lent: lent,
|
|
1014
1017
|
message: r.created ? 'Your instance will be torn down shortly.' : 'A teardown is already queued for that instance.' });
|
|
1015
1018
|
} catch (err) {
|
|
1016
1019
|
log.error('[provisioning] POST /provisioning/instances/:id/teardown', err);
|
|
@@ -25,6 +25,7 @@ const renderStandup = require('../render-standup');
|
|
|
25
25
|
const { failFrom } = require('../public-refusal');
|
|
26
26
|
const { lookupRender, RenderLookupError } = require('../render-lookup');
|
|
27
27
|
const { appStatus } = require('../app-status');
|
|
28
|
+
const renderSandbox = require('../render-sandbox');
|
|
28
29
|
const envManifest = require('../env-manifest');
|
|
29
30
|
const { makeAskRateLimiter } = require('../rate-limit');
|
|
30
31
|
|
|
@@ -51,6 +52,8 @@ module.exports = function buildRenderStandupRouter({ fetchImpl } = {}) {
|
|
|
51
52
|
const router = express.Router();
|
|
52
53
|
const lookupLimit = makeAskRateLimiter({ windowMs: 60_000, max: 20, key: (req) => `b:${req.builder && req.builder.id}` });
|
|
53
54
|
const statusLimit = makeAskRateLimiter({ windowMs: 60_000, max: 60, key: (req) => `s:${req.builder && req.builder.id}` });
|
|
55
|
+
// Adding a sandbox queues work on the owner's account, so a handful a minute is plenty.
|
|
56
|
+
const sandboxLimit = makeAskRateLimiter({ windowMs: 60_000, max: 10, key: (req) => `x:${req.builder && req.builder.id}` });
|
|
54
57
|
|
|
55
58
|
// POST /provisioning/render/lookup — rank: any authenticated builder. What the owner's
|
|
56
59
|
// Render key can see: its workspaces, and the web services in the one they name. The
|
|
@@ -100,13 +103,58 @@ module.exports = function buildRenderStandupRouter({ fetchImpl } = {}) {
|
|
|
100
103
|
const envVars = app && app.state !== 'disconnected'
|
|
101
104
|
? (await envManifest.listEnvVars(pool, id)).map((v) => ({ name: v.name, required: v.required !== false, set_for_you: dbSet && v.name === 'DATABASE_URL' }))
|
|
102
105
|
: [];
|
|
103
|
-
|
|
106
|
+
// The sandbox beside it (ADR 0406, task 1005289), in the same shape.
|
|
107
|
+
const sandbox = renderSandbox.sandboxStatus(inst);
|
|
108
|
+
res.json({ ok: true, app, sandbox, env_vars: envVars, values_held: false });
|
|
104
109
|
} catch (err) {
|
|
105
110
|
log.error('[provisioning] GET /provisioning/instances/:id/render', err && err.message);
|
|
106
111
|
failFrom(res, err, 'render_status_failed');
|
|
107
112
|
}
|
|
108
113
|
});
|
|
109
114
|
|
|
115
|
+
// POST /provisioning/instances/:id/render/sandbox — rank: any authenticated builder, and
|
|
116
|
+
// OWNER ONLY, like every Render route. An app already on Render adds its sandbox with
|
|
117
|
+
// one action (ADR 0406, task 1005289): the owner lends the key once, and the runner sets
|
|
118
|
+
// up a free app that follows main, beside the live one, which it does not touch. A
|
|
119
|
+
// project with no app on Render is told why it has none. The reply is a receipt.
|
|
120
|
+
router.post('/provisioning/instances/:id/render/sandbox', api.requireBuilder, sandboxLimit, async (req, res) => {
|
|
121
|
+
const id = parseId(req, res, { positiveInt: true });
|
|
122
|
+
if (!id) return;
|
|
123
|
+
if (validateOrRespond(req, res, { key: { required: true, type: 'string', maxLength: 200 } })) return;
|
|
124
|
+
try {
|
|
125
|
+
const inst = await provisioning.getInstanceById(pool, id);
|
|
126
|
+
if (!ownedBy(inst, req.builder)) return res.fail('instance_not_found', 404);
|
|
127
|
+
if (!renderStandup.KEY_RE.test(req.body.key)) {
|
|
128
|
+
return res.fail('render_request_invalid', { status: 400, message: 'That doesn’t look like a Render API key. It starts with “rnd_”.' });
|
|
129
|
+
}
|
|
130
|
+
const why = renderSandbox.noSandboxReason(inst);
|
|
131
|
+
if (why) return res.fail('sandbox_unavailable', { status: 409, message: why });
|
|
132
|
+
const sb = inst.app_host && inst.app_host.sandbox;
|
|
133
|
+
if (sb && ['creating', 'deploying', 'live'].includes(sb.state)) {
|
|
134
|
+
return res.fail('sandbox_exists', { status: 409, message: 'This project already has a sandbox.' });
|
|
135
|
+
}
|
|
136
|
+
if (!api.secretBox.isConfigured()) {
|
|
137
|
+
return res.fail('render_unavailable', { status: 503, message: 'The platform can’t take a key safely right now, so nothing was sent. Try again later.' });
|
|
138
|
+
}
|
|
139
|
+
const r = await renderSandbox.enqueueSandboxAdd(pool, id, { keyEnc: api.secretBox.encrypt(req.body.key), requestedBy: 'api:self' });
|
|
140
|
+
if (r.conflict) {
|
|
141
|
+
return res.fail('intent_conflict', { status: 409,
|
|
142
|
+
message: `A '${r.intent.action}' run is ${r.intent.state} for this project, so your key was not kept. Try again once it has finished.`,
|
|
143
|
+
details: { action: r.intent.action, state: r.intent.state } });
|
|
144
|
+
}
|
|
145
|
+
await provisioning.recordEvent(pool, {
|
|
146
|
+
instanceId: id, ownerBuilderId: inst.owner_builder_id, event: 'render-standup',
|
|
147
|
+
detail: 'Render sandbox requested by owner', actor: 'api:self',
|
|
148
|
+
}).catch(() => {});
|
|
149
|
+
res.json({ ok: true, queued: true, action: 'render-standup', sandbox: true,
|
|
150
|
+
message: 'Queued. The platform will add a free sandbox app on your Render account that updates with every finished change. Your live app is not touched. Your key is used once and then thrown away.' });
|
|
151
|
+
} catch (err) {
|
|
152
|
+
// Never log the request: it carries the key.
|
|
153
|
+
log.error('[provisioning] POST /provisioning/instances/:id/render/sandbox', err && err.message);
|
|
154
|
+
failFrom(res, err, 'render_sandbox_failed');
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
|
|
110
158
|
// POST /provisioning/instances/:id/render — rank: any authenticated builder, and OWNER
|
|
111
159
|
// ONLY: the key is the owner's and so is the account it acts in, so an archon on
|
|
112
160
|
// someone else's project gets the same 404 as a stranger. The reply is a receipt;
|