@bongos/core 1.21.70 → 1.21.72
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 +123 -63
- package/.claude/skills/goal-create/SKILL.md +21 -3
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +2 -0
- package/clients/bongos-client/index.cjs +2 -0
- package/clients/bongos-client/index.d.ts +5 -3
- package/clients/bongos-client/index.mjs +2 -0
- package/docs/adr/0368-red-and-blue-teams-practice-on-a-range-never-on-live.md +99 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +46 -5
- package/docs/api-reference.md +4 -3
- package/docs/copy-inventory.md +388 -377
- package/docs/copy-registry.json +567 -448
- package/docs/file-map.md +1 -1
- package/docs/module-api-changelog.md +4 -0
- package/docs/page-readings.json +912 -931
- package/docs/recipes/core-release-pipeline.md +6 -0
- package/docs/recipes/upgrading-the-core.md +2 -0
- package/modules/hall-ui/public/settings-software-update.js +77 -2
- package/modules/hall-ui/public/settings.html +1 -1
- package/modules/lifecycle/db-tasks.js +12 -0
- package/modules/lifecycle/goal-spec-task.js +129 -0
- package/modules/lifecycle/routes/goals.js +7 -9
- package/modules/lifecycle/routes/task-write-routes.js +11 -1
- package/modules/lifecycle/upkeep-home.js +55 -0
- package/modules/public-landing/public/projects-hall-live.states.json +7 -1
- package/modules/public-landing/public/projects.html +581 -0
- package/modules/public-landing/public/projects.probes.json +2 -2
- package/modules/public-landing/public/projects.states.json +1 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +43 -0
- package/scripts/gds/claude-materialize.js +125 -31
- package/scripts/gds/claude-md-template.js +47 -10
- package/scripts/gds/cli-lib.js +4 -4
- package/scripts/gds/feedback-send.js +3 -3
- package/scripts/gds/paste-token.js +1 -1
- package/scripts/gds/provision-net.js +1 -8
- package/scripts/gds/provision-units.js +28 -2
- package/scripts/gds/reauth.js +1 -1
- package/scripts/gds/run-unit-tests.js +3 -0
- package/scripts/gds/setup.js +1 -1
- package/scripts/gds/start.js +4 -4
- package/scripts/gds/surface.js +15 -3
- package/scripts/gds/task.js +32 -5
- package/scripts/gds/upgrade-operator.js +73 -0
- package/scripts/gds/upgrade.js +7 -2
- package/scripts/hall-preview/server.js +3 -0
- package/src/bongos/routes/core-update.js +98 -4
- package/src/bongos/routes/feedback.js +11 -1
- package/src/bongos/upgrade-request.js +158 -0
- package/src/module-api.js +1 -1
- package/tests/claude_materialize.mjs +195 -18
- package/tests/cli_allowlist_permissions.mjs +6 -2
- package/tests/cli_surface.mjs +2 -2
- package/tests/fixtures/page-ledger.mjs +5 -0
- package/tests/goal_admission_override.mjs +1 -1
- package/tests/goal_category.mjs +1 -1
- package/tests/goal_create_spec_entry.mjs +61 -0
- package/tests/goal_spec_task.mjs +197 -0
- package/tests/goal_spec_task_db.mjs +95 -0
- package/tests/projects_hub_cloud_hop.mjs +303 -0
- package/tests/provision_settings_apply.mjs +25 -1
- package/tests/software_update.mjs +18 -1
- package/tests/start_env_guard.mjs +2 -2
- package/tests/upgrade_operator.mjs +105 -0
- package/tests/upgrade_request.mjs +348 -0
- package/tests/upkeep_home.mjs +156 -0
|
@@ -6,6 +6,12 @@
|
|
|
6
6
|
>
|
|
7
7
|
> A change that has landed on `main` is running on **zero** instances. It reaches users only after someone bumps `CORE_VERSION`, cuts an artifact, and moves an instance's pin. Those are three separate acts by design ([ADR 0137](../adr/0137-upstream-publish-policy-manual-owner-gated.md)) — and every layer around them reports success in the meantime.
|
|
8
8
|
|
|
9
|
+
> ## Working on a hosted project? Stop here — nothing in this file is yours to run.
|
|
10
|
+
>
|
|
11
|
+
> If the project you are building is run for you by an operator (it lives on their server; `bongos doctor` says *"Deploy mode: laptop — the operator lands ships"*; `bongos upgrade` says *"This project is upgraded by its operator"*), **the operator upgrades it**. Every command below runs on that server, which you cannot reach. The supported path is to ask: open **Settings → Software update** in the project and use the button there, or from a terminal run `bongos feedback "Please upgrade this project to the latest version"`.
|
|
12
|
+
>
|
|
13
|
+
> **For an assistant:** do not turn this recipe into steps for that builder — no ssh, no "log in as the account that owns the directory", and never a guessed database, service or health-check name. A placeholder you cannot fill in is a sign you are on the wrong path (task 1004596).
|
|
14
|
+
|
|
9
15
|
## Why this file exists
|
|
10
16
|
|
|
11
17
|
Between 2026-07-24 and 2026-08-03, **93 work commits (171 counting merges) — including the entire governance permission system — sat on `main` unreleased for ten days** while the live platform served the previous version. Nothing was broken. `/builder-ship` printed success, the publish-reconciler flipped `confirmed → shipped`, credits paid out. Every signal a builder could see said "delivered"; nothing compared live against `main` (blocker 1000097, task [1002521](https://example.com/builders#/task/1002521)).
|
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
>
|
|
5
5
|
> The R86 update-consumption channel ([ADR 0100](../adr/0100-two-repo-instance-model.md) §2, [ADR 0103](../adr/0103-core-first-extraction-cloud-bongos-trunk.md) §4, task [#1690](https://example.com/builders#/task/1690)). How a **two-repo consumer instance** — one that installs `@bongos/core` as a vendored-tarball dependency — pulls the core from version N → N+1. Tooling: [`scripts/gds/upgrade.js`](../../scripts/gds/upgrade.js) (`bongos upgrade`). First proven live on emersonian-circles 1.15.0 → 1.16.0 (task [#1976](https://example.com/builders#/task/1976)).
|
|
6
6
|
|
|
7
|
+
> **Not for a hosted project's builders.** Everything here runs on the server where the project lives. On a project its operator runs, `bongos upgrade` from your own computer says so and stops; ask for the upgrade through **Settings → Software update** or `bongos feedback "…"` instead — never write server steps or guess its names (task 1004596; [the release pipeline](core-release-pipeline.md) has the detail).
|
|
8
|
+
|
|
7
9
|
## What the channel does
|
|
8
10
|
|
|
9
11
|
`bongos upgrade --to <version> --from <tgz>` runs, from a consumer instance root:
|
|
@@ -65,6 +65,49 @@
|
|
|
65
65
|
</div>`;
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
+
// "Ask for this upgrade" (task 1004595). A project the hub's operator runs cannot take an
|
|
69
|
+
// update itself, so instead of a dead end the page offers one press that reaches the people
|
|
70
|
+
// who run its server, and then says that it was asked. The server decides whether it is
|
|
71
|
+
// offered (`ask.available`) and remembers the request (`ask.requested`).
|
|
72
|
+
function longDate(iso) {
|
|
73
|
+
const d = new Date(iso);
|
|
74
|
+
if (!Number.isFinite(d.getTime())) return null;
|
|
75
|
+
try { return d.toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' }); } catch (e) { return d.toISOString().slice(0, 10); }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Asked, and still waiting: for this very version, or recently enough that asking again
|
|
79
|
+
// would only be refused.
|
|
80
|
+
function askPending(ask, offered) {
|
|
81
|
+
const r = ask && ask.requested;
|
|
82
|
+
if (!r) return false;
|
|
83
|
+
if (r.version === offered) return true;
|
|
84
|
+
const again = Date.parse(ask.ask_again_after);
|
|
85
|
+
return Number.isFinite(again) && Date.now() < again;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function askHtml(ask, offered, { lead }) {
|
|
89
|
+
const team = ask.hub_host ? `the team at ${escapeHtml(ask.hub_host)}` : 'the team that runs this project\'s server';
|
|
90
|
+
if (askPending(ask, offered)) {
|
|
91
|
+
const r = ask.requested;
|
|
92
|
+
const when = longDate(r.requested_at);
|
|
93
|
+
return `<div class="su-door su-ask">
|
|
94
|
+
<p class="panel-status panel-status--ok" role="status">Upgrade requested${when ? ` on ${escapeHtml(when)}` : ''}${r.by ? ` by @${escapeHtml(r.by)}` : ''}, for version ${mono(r.version)}. ${team.charAt(0).toUpperCase() + team.slice(1)} has your request. This page shows the new version once it is installed.</p>
|
|
95
|
+
</div>`;
|
|
96
|
+
}
|
|
97
|
+
const last = ask.requested && longDate(ask.requested.requested_at);
|
|
98
|
+
const before = last ? ` You last asked on ${escapeHtml(last)}, for ${mono(ask.requested.version)}.` : '';
|
|
99
|
+
const intro = lead
|
|
100
|
+
? `<p class="panel-hint">This project runs on a server that ${team} looks after, so they install its updates. Ask them for this one and they are told which version you want.${before}</p>`
|
|
101
|
+
: `<p class="panel-hint">If that page does not let you in, ask ${team} to install it for you.${before}</p>`;
|
|
102
|
+
return `<div class="su-door su-ask">
|
|
103
|
+
${intro}
|
|
104
|
+
<div class="row-end">
|
|
105
|
+
<button type="button" class="pbtn ${lead ? 'pbtn--save' : 'pbtn--quiet'}" id="su-ask">Ask for this upgrade</button>
|
|
106
|
+
<span class="panel-hint" id="su-ask-status" role="status"></span>
|
|
107
|
+
</div>
|
|
108
|
+
</div>`;
|
|
109
|
+
}
|
|
110
|
+
|
|
68
111
|
// Where the Update button goes, and what it says it will do. Only a plain same-origin path
|
|
69
112
|
// or an http(s) URL is ever put in an href; anything else draws no button.
|
|
70
113
|
function doorHtml(data, offered) {
|
|
@@ -72,7 +115,9 @@
|
|
|
72
115
|
if (!data.can_update) {
|
|
73
116
|
return '<p class="panel-hint su-door">Only the people allowed to upgrade this project see an Update button here.</p>';
|
|
74
117
|
}
|
|
118
|
+
const ask = data.ask || {};
|
|
75
119
|
if (door.kind === 'cli') {
|
|
120
|
+
if (ask.available) return askHtml(ask, offered, { lead: true });
|
|
76
121
|
return '<p class="panel-hint su-door">This project is upgraded on the server that runs it: <code>bongos upgrade</code> there takes this version, with the same health check and rollback.</p>';
|
|
77
122
|
}
|
|
78
123
|
const url = String(door.url || '');
|
|
@@ -85,7 +130,7 @@
|
|
|
85
130
|
return `<div class="row-end su-door">
|
|
86
131
|
<a class="pbtn pbtn--save" id="su-update" href="${escapeHtml(url)}">Update to ${escapeHtml(offered)}${sameOrigin ? '' : ' ↗'}</a>
|
|
87
132
|
<span class="panel-hint">Opens this project's upgrade${escapeHtml(where)}: you see what would move, confirm it, and it installs, checks itself, and rolls back if the project does not come back healthy.</span>
|
|
88
|
-
</div
|
|
133
|
+
</div>${door.kind === 'hub' && ask.available ? askHtml(ask, offered, { lead: false }) : ''}`;
|
|
89
134
|
}
|
|
90
135
|
|
|
91
136
|
// One entry: its text, and — only in the hall whose ledger the ids belong to, which is
|
|
@@ -142,7 +187,8 @@
|
|
|
142
187
|
html += row('Newest for this project', `${mono(u.running)} — this project is up to date.`, '<span class="su-pill su-pill--ok">Up to date</span>');
|
|
143
188
|
} else {
|
|
144
189
|
const when = ago(u.offered_published_at);
|
|
145
|
-
|
|
190
|
+
const pending = data.can_update && data.ask && data.ask.available && askPending(data.ask, u.offered);
|
|
191
|
+
html += row('Newest for this project', `${mono(u.offered)}${when ? ` — published ${escapeHtml(when)}` : ''}`, `<span class="su-pill">${pending ? 'Requested' : 'Update ready'}</span>`);
|
|
146
192
|
html += doorHtml(data, u.offered);
|
|
147
193
|
html += whatsNewHtml(u.whats_new, (data.door || {}).kind === 'local');
|
|
148
194
|
}
|
|
@@ -154,6 +200,9 @@
|
|
|
154
200
|
body.innerHTML = html;
|
|
155
201
|
if (note) note.textContent = notes.join(' ');
|
|
156
202
|
|
|
203
|
+
const askBtn = byId('su-ask');
|
|
204
|
+
if (askBtn) askBtn.addEventListener('click', () => askForUpgrade(data, askBtn));
|
|
205
|
+
|
|
157
206
|
const more = byId('su-more');
|
|
158
207
|
if (more) {
|
|
159
208
|
more.addEventListener('click', () => {
|
|
@@ -163,6 +212,32 @@
|
|
|
163
212
|
}
|
|
164
213
|
}
|
|
165
214
|
|
|
215
|
+
// One press: the server sends the request and remembers it; the panel redraws from its
|
|
216
|
+
// answer. "Asked recently" carries the stored request, so it redraws as pending too.
|
|
217
|
+
async function askForUpgrade(data, btn) {
|
|
218
|
+
const status = byId('su-ask-status');
|
|
219
|
+
btn.disabled = true;
|
|
220
|
+
btn.textContent = 'Asking…';
|
|
221
|
+
if (status) status.textContent = '';
|
|
222
|
+
try {
|
|
223
|
+
const res = await api.request('POST', `${API}/software-update/request`, { body: {} });
|
|
224
|
+
const err = (res.data && res.data.error) || {};
|
|
225
|
+
const request = (res.data && res.data.request) || (err.details && err.details.request);
|
|
226
|
+
if ((res.ok || res.status === 429) && request) {
|
|
227
|
+
const again = new Date(Date.parse(request.requested_at) + 24 * 3600 * 1000).toISOString();
|
|
228
|
+
render({ ...data, ask: { ...data.ask, requested: request, ask_again_after: again } });
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
// Every refusal the server sends carries a sentence; a bare code is only ever a
|
|
232
|
+
// proxy or a crash, so the status stands in for it.
|
|
233
|
+
throw new Error(err.message && err.message !== err.code ? err.message : `HTTP ${res.status}`);
|
|
234
|
+
} catch (e) {
|
|
235
|
+
btn.disabled = false;
|
|
236
|
+
btn.textContent = 'Ask for this upgrade';
|
|
237
|
+
if (status) status.textContent = `Could not send the request: ${String(e.message || 'unknown error').replace(/\.$/, '')}.`;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
166
241
|
async function load() {
|
|
167
242
|
if (asked) return;
|
|
168
243
|
asked = true;
|
|
@@ -461,7 +461,7 @@
|
|
|
461
461
|
settings.js is against the 1500-line ratchet. -->
|
|
462
462
|
<script src="/builders/settings-render.js?v=2026-09-16-render-levels"></script>
|
|
463
463
|
<!-- task 1004296: Settings → Software update. Own file on the same precedent. -->
|
|
464
|
-
<script src="/builders/settings-software-update.js?v=2026-
|
|
464
|
+
<script src="/builders/settings-software-update.js?v=2026-10-06-ask"></script>
|
|
465
465
|
<!-- task 1004501: Settings → Your Autobongos runner. Own file on the same precedent. -->
|
|
466
466
|
<script src="/builders/settings-autobongos.js?v=2026-10-01-autobongos"></script>
|
|
467
467
|
</body>
|
|
@@ -20,6 +20,7 @@ const { deriveRequiredRank, highestRank } = require('./db-rank-authz.js');
|
|
|
20
20
|
const { executorFor } = require('./db-executor.js');
|
|
21
21
|
const { isReservedCriterionSlug } = require('./criterion-ref.js');
|
|
22
22
|
const { resolveWriteDiscipline, WRITABLE_DISCIPLINES } = require('./task-classifier.js');
|
|
23
|
+
const { gateOnSpecTask } = require('./goal-spec-task.js');
|
|
23
24
|
|
|
24
25
|
// -------------------------------------------------------------------------
|
|
25
26
|
// Tasks
|
|
@@ -293,6 +294,10 @@ async function createTask({
|
|
|
293
294
|
`[reward-gate] AUTO-ASSIGN task ${rows[0].id} credits_reward -> ${creditsRewardEffective} (cap ${REWARD_SUGGESTION_CAP}) [creation]`
|
|
294
295
|
);
|
|
295
296
|
}
|
|
297
|
+
// task 1004585 / ADR 0366 D3: a task born into a goal whose spec task is still
|
|
298
|
+
// open waits on it. Here, at the lowest create layer, so every vector inherits it;
|
|
299
|
+
// inside the caller's transaction when there is one, like the INSERT above.
|
|
300
|
+
if (rows[0]) await gateOnSpecTask(rows[0], deps.client || pool);
|
|
296
301
|
return rows[0];
|
|
297
302
|
}
|
|
298
303
|
|
|
@@ -417,6 +422,13 @@ async function updateTaskGoal(id, goalId) {
|
|
|
417
422
|
`UPDATE tasks SET goal_id = $1, updated_at = now() WHERE id = $2 RETURNING *`,
|
|
418
423
|
[goalId, id]
|
|
419
424
|
);
|
|
425
|
+
// task 1004585: a task MOVED into a goal waits on its open spec task too. Through
|
|
426
|
+
// addTaskDependency (lazy — it lives beside this file), because a moved task may
|
|
427
|
+
// already have dependents and the edge must pass the cycle check.
|
|
428
|
+
if (rows[0]) {
|
|
429
|
+
const { addTaskDependency } = require('./db-deps-criteria.js');
|
|
430
|
+
await gateOnSpecTask(rows[0], pool, { addTaskDependency });
|
|
431
|
+
}
|
|
420
432
|
return rows[0] ?? null;
|
|
421
433
|
}
|
|
422
434
|
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// modules/lifecycle/goal-spec-task.js — a goal made in the hall carries its own
|
|
4
|
+
// spec task, and the goal's other work waits behind it (task 1004585, ADR 0366 D3).
|
|
5
|
+
//
|
|
6
|
+
// WHY. The hall's goal form asks only what a person knows (a name, a description,
|
|
7
|
+
// a version, a category). The rest of a goal — its scope wall, its criteria, its
|
|
8
|
+
// first tasks — is inference, which /goal-create does in a session (ADR 0366 D2).
|
|
9
|
+
// So a hall-made goal arrives with a task that says exactly that, and nothing else
|
|
10
|
+
// in the goal can be claimed until it ships: an unspecced goal is visible as a
|
|
11
|
+
// task on the board, not a silence.
|
|
12
|
+
//
|
|
13
|
+
// HOW, with no new permission and no migration:
|
|
14
|
+
// - The spec task is an ordinary task, recognised by (source, source_ref) =
|
|
15
|
+
// ('goal-spec', 'goal-<id>-spec'). That pair is also what makes creating it
|
|
16
|
+
// idempotent (findTaskBySourceRef).
|
|
17
|
+
// - "Waits behind it" is an ordinary task→task dependency, so the claim gate's
|
|
18
|
+
// existing DEPS_NOT_SHIPPED refusal and the auto-promotion on ship do the rest.
|
|
19
|
+
// A goal-level edge would be simpler, but every task in the goal inherits it
|
|
20
|
+
// (unsatisfiedDeps), the spec task included, which would block it on itself.
|
|
21
|
+
// - The edge is added at the two places a task ENTERS a goal: db-tasks.createTask
|
|
22
|
+
// (every create vector passes through it) and db-tasks.updateTaskGoal (a move).
|
|
23
|
+
|
|
24
|
+
const SPEC_SOURCE = 'goal-spec';
|
|
25
|
+
|
|
26
|
+
function specSourceRef(goalId) {
|
|
27
|
+
return `goal-${goalId}-spec`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// The spec task's fields, for db.createTask. `ready` so it can be claimed at once;
|
|
31
|
+
// a spike, because its output is a decision (the scope and the criteria) rather
|
|
32
|
+
// than code; ideator, the craft whose pack covers "the scopes of work ideas become".
|
|
33
|
+
function specTaskFields(goal, { createdBy = null } = {}) {
|
|
34
|
+
const id = goal.id;
|
|
35
|
+
return {
|
|
36
|
+
versionId: goal.version_id,
|
|
37
|
+
goalId: id,
|
|
38
|
+
title: `Spec goal ${id}: its scope, criteria and first tasks`,
|
|
39
|
+
description: [
|
|
40
|
+
`Goal ${id} ("${goal.title}") was created from the hall, which asks only for a name and a description. This task settles the rest, and every other task in the goal waits on it (ADR 0366 D3).`,
|
|
41
|
+
'',
|
|
42
|
+
'Settle, in a session (run /goal-create against this goal):',
|
|
43
|
+
`1. The scope wall: which code areas the goal's tasks may touch. Set it with POST /goals/${id}/scope (the goal's owner or a manager; a protected area needs an Archon).`,
|
|
44
|
+
`2. Two to four done-when criteria, with POST /versions/${goal.version_id}/done-when under the goal (ADR 0154: the goal's owner or a manager).`,
|
|
45
|
+
'3. The first tasks, with their dependencies.',
|
|
46
|
+
'',
|
|
47
|
+
'If you do not own or manage this goal, draft the scope and criteria and hand them to its owner rather than skipping them.',
|
|
48
|
+
'',
|
|
49
|
+
'DONE WHEN: the goal has its scope (or a written reason it needs none), its criteria, and at least one claimable task.',
|
|
50
|
+
].join('\n'),
|
|
51
|
+
status: 'ready',
|
|
52
|
+
kind: 'spike',
|
|
53
|
+
discipline: 'ideator',
|
|
54
|
+
source: SPEC_SOURCE,
|
|
55
|
+
sourceRef: specSourceRef(id),
|
|
56
|
+
createdBy,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// The goal's spec task if it is still open, else null. An abandoned spec task
|
|
61
|
+
// gates nothing new (the owner decided the goal needs no spec); a shipped one is done.
|
|
62
|
+
async function openSpecTaskId(goalId, client) {
|
|
63
|
+
if (goalId == null) return null;
|
|
64
|
+
const { rows } = await client.query(
|
|
65
|
+
`SELECT id FROM tasks
|
|
66
|
+
WHERE goal_id = $1 AND source = $2 AND source_ref = $3
|
|
67
|
+
AND status NOT IN ('shipped', 'abandoned')
|
|
68
|
+
ORDER BY id ASC
|
|
69
|
+
LIMIT 1`,
|
|
70
|
+
[goalId, SPEC_SOURCE, specSourceRef(goalId)]
|
|
71
|
+
);
|
|
72
|
+
return rows[0] ? Number(rows[0].id) : null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Make a task that just entered a goal wait on that goal's open spec task.
|
|
76
|
+
// Returns the spec task id it now waits on, or null. A task that IS the spec task
|
|
77
|
+
// is left alone. Safe without a cycle check only for a brand-new task (nothing can
|
|
78
|
+
// depend on it yet) — a moved task goes through addTaskDependency, which checks.
|
|
79
|
+
async function gateOnSpecTask(task, client, { addTaskDependency = null } = {}) {
|
|
80
|
+
if (!task || task.goal_id == null) return null;
|
|
81
|
+
if (task.source === SPEC_SOURCE && task.source_ref === specSourceRef(task.goal_id)) return null;
|
|
82
|
+
const specId = await openSpecTaskId(task.goal_id, client);
|
|
83
|
+
if (specId == null || specId === Number(task.id)) return null;
|
|
84
|
+
if (addTaskDependency) {
|
|
85
|
+
try {
|
|
86
|
+
await addTaskDependency(task.id, specId);
|
|
87
|
+
} catch (err) {
|
|
88
|
+
// A cycle means the spec task already waits on this task; gating it would
|
|
89
|
+
// deadlock both, so the move stands without the edge.
|
|
90
|
+
if (err && err.code === 'CYCLE') return null;
|
|
91
|
+
throw err;
|
|
92
|
+
}
|
|
93
|
+
} else {
|
|
94
|
+
await client.query(
|
|
95
|
+
`INSERT INTO dependencies (from_kind, from_id, to_kind, to_id)
|
|
96
|
+
VALUES ('task', $1, 'task', $2)
|
|
97
|
+
ON CONFLICT DO NOTHING`,
|
|
98
|
+
[task.id, specId]
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
return specId;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Give a goal its spec task, safely re-runnable: if the goal already has an OPEN
|
|
105
|
+
// spec task, that one is returned and nothing is written, so a second run (a retry,
|
|
106
|
+
// or a future "re-offer the spec" path) never files a duplicate. Run it inside the
|
|
107
|
+
// caller's transaction so the read and the insert see the same snapshot.
|
|
108
|
+
async function ensureSpecTask({ db, goal, client, createdBy = null }) {
|
|
109
|
+
const existing = await openSpecTaskId(goal.id, client);
|
|
110
|
+
if (existing != null) return { specTask: { id: existing }, created: false };
|
|
111
|
+
const specTask = await db.createTask(specTaskFields(goal, { createdBy }), { client });
|
|
112
|
+
return { specTask, created: true };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// POST /goals's create step. Without the opt-in it is exactly db.createGoal. With it,
|
|
116
|
+
// the goal and its spec task land in ONE transaction: a goal without its spec task
|
|
117
|
+
// would be ungated, which is the state D3 exists to end.
|
|
118
|
+
async function createGoalWithSpec({ db, withTx, goalFields, withSpecTask = false }) {
|
|
119
|
+
if (!withSpecTask) return { goal: await db.createGoal(goalFields), specTask: null };
|
|
120
|
+
let goal;
|
|
121
|
+
let specTask = null;
|
|
122
|
+
await withTx(async (client) => {
|
|
123
|
+
goal = await db.createGoal(goalFields, { client });
|
|
124
|
+
({ specTask } = await ensureSpecTask({ db, goal, client, createdBy: goalFields.createdBy }));
|
|
125
|
+
});
|
|
126
|
+
return { goal, specTask };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
module.exports = { SPEC_SOURCE, specSourceRef, specTaskFields, openSpecTaskId, gateOnSpecTask, ensureSpecTask, createGoalWithSpec };
|
|
@@ -29,6 +29,7 @@ const hierarchy = require('../hierarchy');
|
|
|
29
29
|
const goalSuggest = require('../goal-suggest.js'); // task 1003062 — pre-creation goal suggester
|
|
30
30
|
const goalEdits = require('../goal-edits.js'); // task 1003102 — goal self-edit handlers
|
|
31
31
|
const goalCategory = require('../goal-category.js'); // task 1003275 — the ADVISORY human category (gates nothing)
|
|
32
|
+
const goalSpecTask = require('../goal-spec-task.js'); // task 1004585 — a hall-made goal carries its own spec task (ADR 0366 D3)
|
|
32
33
|
const goalRollup = require('../goal-rollup.js'); // task 1003278 — per-goal rollups computed in SQL, not in the browser
|
|
33
34
|
const goalGraph = require('../goal-graph.js'); // task 1003479 — the goal graph: nodes + cross-goal edges, aggregated in SQL
|
|
34
35
|
const goalForest = require('../goal-forest.js'); // task 1003480 — per-goal dependency forest
|
|
@@ -97,6 +98,7 @@ module.exports = function buildGoalsRouter() {
|
|
|
97
98
|
// requested without stating a cause. minLength 8 because 'ok' / '.' is not
|
|
98
99
|
// a reason, and the row is the only place the why survives.
|
|
99
100
|
admit_reason: { type: 'string', minLength: 8, maxLength: 500 },
|
|
101
|
+
spec_task: { type: 'boolean' }, // task 1004585 / ADR 0366 D3 — the hall form's opt-in (goal-spec-task.js)
|
|
100
102
|
})) return;
|
|
101
103
|
|
|
102
104
|
const scopeModules = req.body.scope_modules || [];
|
|
@@ -156,15 +158,11 @@ module.exports = function buildGoalsRouter() {
|
|
|
156
158
|
const admission = versionDecision.overrideApplied
|
|
157
159
|
? { admittedBy: req.builder.id, reason: admitReason }
|
|
158
160
|
: null;
|
|
159
|
-
const goal = await
|
|
160
|
-
versionId: req.body.version_id,
|
|
161
|
-
title: req.body.title,
|
|
161
|
+
const { goal, specTask } = await goalSpecTask.createGoalWithSpec({ db, withTx: api.withTx, withSpecTask: req.body.spec_task === true, goalFields: {
|
|
162
|
+
versionId: req.body.version_id, title: req.body.title,
|
|
162
163
|
subtitle: (req.body.subtitle || '').trim() || null, description: (req.body.description || '').trim() || null,
|
|
163
|
-
scopeModules,
|
|
164
|
-
|
|
165
|
-
categoryId,
|
|
166
|
-
admission,
|
|
167
|
-
});
|
|
164
|
+
scopeModules, createdBy: req.builder.id, categoryId, admission,
|
|
165
|
+
} });
|
|
168
166
|
// Stamp the predecessor's succeeded_by pointer at the new goal (the lineage).
|
|
169
167
|
if (succeedsId != null) {
|
|
170
168
|
await db.setGoalSuccessor({ goalId: succeedsId, successorId: goal.id });
|
|
@@ -182,7 +180,7 @@ module.exports = function buildGoalsRouter() {
|
|
|
182
180
|
// waved into a building version, by whom and why — the caller should not
|
|
183
181
|
// have to re-read the goal to find out whether their override took effect.
|
|
184
182
|
const admitted = admission ? await db.getGoalAdmission(goal.id) : null;
|
|
185
|
-
return res.status(201).json({ ok: true, goal, succeeds_goal_id: succeedsId, category_advisory, admitted });
|
|
183
|
+
return res.status(201).json({ ok: true, goal, succeeds_goal_id: succeedsId, category_advisory, admitted, spec_task: specTask });
|
|
186
184
|
} catch (err) {
|
|
187
185
|
log.error('[gds] POST /goals', err);
|
|
188
186
|
return res.fail('goal_create_failed', { status: 500, message: 'internal error' });
|
|
@@ -29,6 +29,7 @@ const { validateOrRespond, LIMITS, parseId } = api;
|
|
|
29
29
|
const { badKind, detectsMigration } = require('./task-route-helpers.js');
|
|
30
30
|
const { describeCheckViolation } = require('../pg-constraint-fail.js');
|
|
31
31
|
const { goalNotOpenRefusal } = require('./goal-route-authz.js');
|
|
32
|
+
const { resolveUpkeepHome, isUpkeepFiling, UPKEEP_HINT } = require('../upkeep-home.js'); // task 1004597
|
|
32
33
|
// task 1003208: structured logging (pino via the doorway) — was console.*.
|
|
33
34
|
const log = api.logger('lifecycle');
|
|
34
35
|
|
|
@@ -189,10 +190,18 @@ module.exports = function registerTaskWriteRoutes(router) {
|
|
|
189
190
|
// nobody reads. This is the human/agent door, and it always has a goal to
|
|
190
191
|
// name — /builder-start lists work by goal, and POST /goals/:id/tasks has
|
|
191
192
|
// required one since BV1.R60.
|
|
193
|
+
// task 1004597: the one exception is PLATFORM UPKEEP (a core upgrade, Bongos
|
|
194
|
+
// maintenance), which fits no product goal. An explicit kind 'infra' with no
|
|
195
|
+
// goal lands in the version's maintenance goal — see upkeep-home.js.
|
|
196
|
+
const upkeepHome = await resolveUpkeepHome(body, { ensureMaintenanceGoal: db.ensureMaintenanceGoal, withTx: api.withTx });
|
|
197
|
+
if (upkeepHome) body.goal_id = Number(upkeepHome.id);
|
|
192
198
|
if (body.goal_id == null) {
|
|
193
199
|
return res.fail('goal_id_required', {
|
|
194
200
|
status: 400,
|
|
195
|
-
message: 'goal_id is required — every task belongs to a goal. List them at GET /goals?version=<id>, or author under one directly with POST /goals/:id/tasks.'
|
|
201
|
+
message: 'goal_id is required — every task belongs to a goal. List them at GET /goals?version=<id>, or author under one directly with POST /goals/:id/tasks. ' +
|
|
202
|
+
(isUpkeepFiling(body)
|
|
203
|
+
? `Version '${body.version_id}' is not being built right now, so it has no maintenance goal for upkeep — file it against the version that is.`
|
|
204
|
+
: UPKEEP_HINT),
|
|
196
205
|
});
|
|
197
206
|
}
|
|
198
207
|
if (body.goal_id != null) {
|
|
@@ -297,6 +306,7 @@ module.exports = function registerTaskWriteRoutes(router) {
|
|
|
297
306
|
needs_migration: needsMigration,
|
|
298
307
|
criteria,
|
|
299
308
|
suggested_criteria,
|
|
309
|
+
...(upkeepHome ? { goal_home: 'maintenance' } : {}), // task 1004597
|
|
300
310
|
goal_advisory, dependency_advisory, est_advisory: await require('../est-advisory').buildEstAdvisory({ db, task }), // task 1003148
|
|
301
311
|
});
|
|
302
312
|
} catch (err) {
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// modules/lifecycle/upkeep-home.js — WHERE PLATFORM UPKEEP GOES WHEN NO PRODUCT
|
|
4
|
+
// GOAL FITS (task 1004597).
|
|
5
|
+
//
|
|
6
|
+
// THE CONTRADICTION THIS CLOSES. The ledger rule says record a change before you
|
|
7
|
+
// make it; the goal rule (ADR 0250 D4, BV1.R11) says every task belongs to a
|
|
8
|
+
// goal. A project's goals are product goals ("Marketing", "Live Floor"), so a
|
|
9
|
+
// builder about to upgrade the Bongos core had nowhere honest to file it: POST
|
|
10
|
+
// /tasks refused `goal_id_required`, and the only ways forward were to misfile
|
|
11
|
+
// the upgrade under a product goal or skip the record. One session (mercury,
|
|
12
|
+
// 2026-10-06) chose the latter.
|
|
13
|
+
//
|
|
14
|
+
// THE ANSWER REUSES THE HOMELESS-BUG HOME. Every building version already has a
|
|
15
|
+
// find-or-create MAINTENANCE goal (ADR 0262, `ensureMaintenanceGoal`): open, no
|
|
16
|
+
// scope wall, exempt from the version-close count, carried forward on close.
|
|
17
|
+
// Platform upkeep is maintenance in exactly that sense, so a task filed with
|
|
18
|
+
// NO goal and kind 'infra' lands there.
|
|
19
|
+
//
|
|
20
|
+
// ONLY AN EXPLICIT kind COUNTS — never the classifier's guess. The defect
|
|
21
|
+
// BV1.R11 fixed was SILENT bucketing (53 tasks filed out of sight by a default).
|
|
22
|
+
// A caller who writes kind 'infra' has said out loud what the work is; a feature
|
|
23
|
+
// whose text merely reads like infra must still name its goal.
|
|
24
|
+
//
|
|
25
|
+
// RANK AND APPROVAL ARE UNTOUCHED. This only answers "which goal"; the route's
|
|
26
|
+
// own permission check (task.create, Metic+) and status rules run exactly as for
|
|
27
|
+
// any other goal.
|
|
28
|
+
|
|
29
|
+
const UPKEEP_KIND = 'infra';
|
|
30
|
+
|
|
31
|
+
// isUpkeepFiling — true when a create names no goal and explicitly says it is
|
|
32
|
+
// platform upkeep. PURE; exported so the rule has one spelling.
|
|
33
|
+
function isUpkeepFiling(body) {
|
|
34
|
+
return !!body && body.goal_id == null && body.kind === UPKEEP_KIND;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// resolveUpkeepHome — the maintenance goal row an upkeep filing lands in, or
|
|
38
|
+
// null when it is not an upkeep filing or the version cannot take work (not
|
|
39
|
+
// `building`). The find-or-create runs inside its own transaction so the
|
|
40
|
+
// advisory lock in ensureMaintenanceGoal actually serialises concurrent filers
|
|
41
|
+
// (outside a transaction it would release at the end of its own statement).
|
|
42
|
+
async function resolveUpkeepHome(body, deps) {
|
|
43
|
+
if (!isUpkeepFiling(body)) return null;
|
|
44
|
+
const { ensureMaintenanceGoal, withTx } = deps;
|
|
45
|
+
return withTx((client) => ensureMaintenanceGoal(body.version_id, { client }));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// The plain-words half of the goal_id_required refusal: what to do when the work
|
|
49
|
+
// is upkeep rather than product work. Shared by the route and the CLI so the two
|
|
50
|
+
// never drift.
|
|
51
|
+
const UPKEEP_HINT =
|
|
52
|
+
'Platform upkeep — a Bongos upgrade or other maintenance — has a standing home: ' +
|
|
53
|
+
`file it with kind "${UPKEEP_KIND}" and no goal, and it goes into this version's maintenance goal.`;
|
|
54
|
+
|
|
55
|
+
module.exports = { UPKEEP_KIND, UPKEEP_HINT, isUpkeepFiling, resolveUpkeepHome };
|