@bongos/core 1.20.59 → 1.20.60

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.
Files changed (89) hide show
  1. package/.bongos-core.json +136 -86
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +10 -0
  4. package/clients/bongos-client/index.cjs +10 -0
  5. package/clients/bongos-client/index.d.ts +13 -0
  6. package/clients/bongos-client/index.mjs +10 -0
  7. package/docs/adr/0358-a-hall-wears-a-look-and-its-night-takes-a-tint.md +52 -0
  8. package/docs/adr/README.md +1 -0
  9. package/docs/api/openapi.json +308 -3
  10. package/docs/api-reference.md +7 -2
  11. package/docs/copy-inventory.md +338 -329
  12. package/docs/copy-registry.json +515 -430
  13. package/docs/module-api-changelog.md +2 -0
  14. package/docs/page-inventory.json +4 -1
  15. package/docs/page-readings.json +757 -706
  16. package/modules/hall-ui/public/atlas.html +1 -1
  17. package/modules/hall-ui/public/blockers.html +1 -1
  18. package/modules/hall-ui/public/board-room.html +1 -1
  19. package/modules/hall-ui/public/collab.html +1 -1
  20. package/modules/hall-ui/public/commands.html +1 -1
  21. package/modules/hall-ui/public/copy-desk.html +1 -1
  22. package/modules/hall-ui/public/deploy.html +1 -1
  23. package/modules/hall-ui/public/diagrams.html +1 -1
  24. package/modules/hall-ui/public/drachmae.html +1 -1
  25. package/modules/hall-ui/public/fleet.html +1 -1
  26. package/modules/hall-ui/public/gate.html +1 -1
  27. package/modules/hall-ui/public/goal-map.html +1 -1
  28. package/modules/hall-ui/public/goals.html +1 -1
  29. package/modules/hall-ui/public/government.html +1 -1
  30. package/modules/hall-ui/public/idea.html +1 -1
  31. package/modules/hall-ui/public/ideas.html +1 -1
  32. package/modules/hall-ui/public/index.html +1 -1
  33. package/modules/hall-ui/public/modules.html +1 -1
  34. package/modules/hall-ui/public/primer.html +1 -1
  35. package/modules/hall-ui/public/profile.html +1 -1
  36. package/modules/hall-ui/public/project-settings.html +1 -1
  37. package/modules/hall-ui/public/ranks.html +1 -1
  38. package/modules/hall-ui/public/roadmap.html +1 -1
  39. package/modules/hall-ui/public/roster.html +1 -1
  40. package/modules/hall-ui/public/sessions.html +1 -1
  41. package/modules/hall-ui/public/settings.html +1 -1
  42. package/modules/hall-ui/public/shell.js +5 -1
  43. package/modules/hall-ui/public/studio.html +1 -1
  44. package/modules/hall-ui/public/style.css +8 -0
  45. package/modules/hall-ui/public/task.html +1 -1
  46. package/modules/hall-ui/public/thinking.html +1 -1
  47. package/modules/hall-ui/public/tweak-editor.html +1 -1
  48. package/modules/hall-ui/public/watch.html +1 -1
  49. package/modules/hall-ui/public/work.html +1 -1
  50. package/modules/provisioning/look.js +169 -0
  51. package/modules/provisioning/migrations/provisioning_034_project_logo.sql +35 -0
  52. package/modules/provisioning/module.json +2 -1
  53. package/modules/provisioning/provisioning.js +4 -3
  54. package/modules/provisioning/routes/look.js +204 -0
  55. package/modules/provisioning/routes/provisioning.js +5 -59
  56. package/modules/provisioning/settings-push.js +76 -0
  57. package/modules/public-landing/public/assets/cosmos.css +1 -1
  58. package/modules/public-landing/public/projects.html +499 -28
  59. package/modules/public-landing/public/projects.probes.json +2 -2
  60. package/modules/public-landing/public/projects.states.json +6 -3
  61. package/modules/ui-design/kit/serve.js +50 -0
  62. package/package-lock.json +2 -2
  63. package/package.json +1 -1
  64. package/release-notes.json +6 -0
  65. package/scripts/gds/fitness.js +4 -0
  66. package/src/bongos/routes.js +4 -1
  67. package/src/bongos/serve-internal.js +6 -0
  68. package/src/branding.js +26 -1
  69. package/src/hall-look.js +99 -0
  70. package/src/module-api.js +1 -1
  71. package/src/night-tint.js +26 -0
  72. package/tests/currency_label.mjs +7 -4
  73. package/tests/hall_tokens.mjs +10 -1
  74. package/tests/hall_tweak_editor.mjs +1 -1
  75. package/tests/look_night_tint.mjs +169 -0
  76. package/tests/nav_permission_atoms.mjs +1 -1
  77. package/tests/projects_hub.mjs +5 -4
  78. package/tests/projects_hub_app_status.mjs +1 -1
  79. package/tests/projects_hub_app_step.mjs +8 -7
  80. package/tests/projects_hub_look.mjs +255 -0
  81. package/tests/projects_hub_module_picker.mjs +2 -2
  82. package/tests/provision_settings_apply.mjs +12 -4
  83. package/tests/provisioning_look.mjs +376 -0
  84. package/tests/provisioning_settings.mjs +10 -7
  85. package/tests/provisioning_settings_apply.mjs +4 -4
  86. package/tests/provisioning_settings_env.mjs +4 -0
  87. package/tests/wizard_draft_resume.mjs +2 -2
  88. package/tests/wizard_front_door.mjs +2 -2
  89. package/tests/wizard_intent_resume.mjs +16 -15
@@ -0,0 +1,204 @@
1
+ // modules/provisioning/routes/look.js — a project's look, night tint and logo
2
+ // (task 1004421, BV2.PS06; ADR 0358; spec D2 + D8).
3
+ //
4
+ // GET /provisioning/instances/:id/look the look as stored: { look, night_tint, look_accent, logo_url }
5
+ // PATCH /provisioning/instances/:id/look { look?, night_tint?, accent? } — a custom look's accent is
6
+ // nudged until it reads in both modes (lookAdjustAccent) and
7
+ // the reply names the colour actually kept
8
+ // PUT /provisioning/instances/:id/logo { image_b64, content_type } — PNG, JPEG or WebP, ≤256 KB
9
+ // DELETE /provisioning/instances/:id/logo
10
+ // GET /provisioning/logos/:file the stored image, public and content-addressed
11
+ //
12
+ // The four own-scoped routes are the owner's, with the archon override (the settings
13
+ // route's rule, 404 for anyone else so an id confirms nothing). Every write that changes
14
+ // what the hall wears answers through settings-push.js, exactly as PATCH …/settings
15
+ // does: an ACTIVE project restarts to pick it up, any other state takes it at standup.
16
+ //
17
+ // Its own routes file (declared in module.json) because routes/provisioning.js sits at
18
+ // the size ratchet — the routes/disconnect.js precedent.
19
+ 'use strict';
20
+
21
+ const express = require('express');
22
+ const api = require('../../../src/module-api');
23
+ const { pool, validateOrRespond, parseId } = api;
24
+ const provisioning = require('../provisioning');
25
+ const look = require('../look');
26
+ const { settingsPush, failPushConflict } = require('../settings-push');
27
+ const { failFrom } = require('../public-refusal');
28
+
29
+ const log = api.logger('provisioning');
30
+
31
+ // The JSON cap for the logo upload: 256 KB decoded is ~342 KB of base64. Two layers,
32
+ // like the task-visual upload: this bounds the ENCODED body, screenLogo the bytes.
33
+ const LOGO_BODY_LIMIT = '400kb';
34
+ const LOGO_B64_MAX = 360 * 1024;
35
+
36
+ // The platform's own public origin — where the hall is told to fetch the logo. The
37
+ // provisioning front door when there is one, else the platform's public origin (the
38
+ // mothershipOrigin rule in routes/provisioning.js). '' when unconfigured.
39
+ function platformOrigin() {
40
+ try {
41
+ const d = api.branding().domains || {};
42
+ return String(d.provisioningOrigin || d.publicOrigin || '').replace(/\/+$/, '');
43
+ } catch { return ''; }
44
+ }
45
+
46
+ async function ownInstance(req, res) {
47
+ const id = parseId(req, res); // sends 400 bad_id + returns null on failure
48
+ if (!id) return null;
49
+ const inst = await provisioning.getInstanceById(pool, id);
50
+ const isOwner = !!inst && String(inst.owner_builder_id) === String(req.builder.id);
51
+ if (!inst || (!isOwner && req.builder.rank !== 'archon')) {
52
+ res.fail('instance_not_found', 404);
53
+ return null;
54
+ }
55
+ return inst;
56
+ }
57
+
58
+ const actorFor = (inst, builder) => (String(inst.owner_builder_id) === String(builder.id) ? 'api:self' : `api:archon:${builder.id}`);
59
+ const choiceOf = (row) => look.lookChoice(row, provisioning.effectiveSettings(row));
60
+
61
+ // Write a look change and answer it — the shared tail of PATCH …/look and both logo
62
+ // writes. `stored` is the jsonb patch; `changed` says whether the hall's look moves.
63
+ async function saveAndPush({ req, res, inst, stored, changed, event, extra = {} }) {
64
+ const id = inst.id;
65
+ const actor = actorFor(inst, req.builder);
66
+ const pushOwed = provisioning.settingsPushOwed(inst);
67
+ // The owner re-kick (the settings route's rule): the same values re-sent over a push
68
+ // that never got carried, or that ran and failed, queue it again.
69
+ const policyResent = provisioning.ownsErrorNote(inst, 'settings-apply') || pushOwed;
70
+ const updated = changed ? await provisioning.updateInstanceSettings(pool, id, stored) : inst;
71
+ if (changed) {
72
+ await provisioning.recordEvent(pool, { instanceId: id, ownerBuilderId: inst.owner_builder_id, event: 'settings', detail: event, actor }).catch(() => {});
73
+ }
74
+ const { push, conflict } = await settingsPush({ pool, provisioning, inst, id, actor, changed, policyChanged: changed,
75
+ policyResent, pushOwed, log, logTag: `${req.method} ${req.route && req.route.path}` });
76
+ if (conflict) return failPushConflict(res, conflict);
77
+ return res.json({ ok: true, look: choiceOf(updated || inst), push, ...extra });
78
+ }
79
+
80
+ module.exports = function buildLookRouter() {
81
+ const router = express.Router();
82
+
83
+ // GET — rank: any authenticated builder (own resource; archon may read any).
84
+ router.get('/provisioning/instances/:id/look', api.requireBuilder, async (req, res) => {
85
+ try {
86
+ const inst = await ownInstance(req, res);
87
+ if (!inst) return;
88
+ res.json({ ok: true, look: choiceOf(inst), options: look.LOOK_SETTINGS_VOCAB, logo_max_bytes: look.LOGO_MAX_BYTES });
89
+ } catch (err) {
90
+ log.error('[provisioning] GET /provisioning/instances/:id/look', err);
91
+ failFrom(res, err, 'look_read_failed');
92
+ }
93
+ });
94
+
95
+ // PATCH — rank: any authenticated builder (own resource; archon may change any). A
96
+ // custom look needs an accent, sent now or already stored; the stored one is the
97
+ // ADJUSTED colour, and `adjusted` in the reply says when it differs from what came in.
98
+ router.patch('/provisioning/instances/:id/look', api.requireBuilder, async (req, res) => {
99
+ if (validateOrRespond(req, res, {
100
+ look: { type: 'string', enum: look.LOOK_SETTINGS_VOCAB.look },
101
+ night_tint: { type: 'string', enum: look.LOOK_SETTINGS_VOCAB.night_tint },
102
+ accent: { type: 'string', maxLength: 16 },
103
+ })) return;
104
+ const body = req.body || {};
105
+ if (body.look === undefined && body.night_tint === undefined && body.accent === undefined) {
106
+ return res.fail('bad_look', { status: 400, message: 'The body carries nothing to change — send any of: look, night_tint, accent.' });
107
+ }
108
+ try {
109
+ const inst = await ownInstance(req, res);
110
+ if (!inst) return;
111
+ const before = provisioning.effectiveSettings(inst);
112
+ const beforeAccent = look.lookAccentOf(inst);
113
+ const stored = {};
114
+ if (body.look !== undefined) stored.look = body.look;
115
+ if (body.night_tint !== undefined) stored.night_tint = body.night_tint;
116
+ let adjusted = null;
117
+ if (body.accent !== undefined) {
118
+ const a = look.lookAdjustAccent(body.accent);
119
+ if (!a) return res.fail('bad_accent', { status: 400, message: 'An accent is a colour written #rrggbb, for example #b48cf2.' });
120
+ stored.look_accent = a.hex;
121
+ if (a.adjusted) adjusted = { from: String(body.accent).trim().toLowerCase(), to: a.hex };
122
+ }
123
+ const nextLook = stored.look || before.look;
124
+ if (nextLook === 'custom' && !(stored.look_accent || beforeAccent)) {
125
+ return res.fail('accent_required', { status: 400, message: 'Your own look needs an accent colour — send accent as #rrggbb.' });
126
+ }
127
+ const changed = Object.entries(stored).some(([k, v]) => (k === 'look_accent' ? beforeAccent !== v : before[k] !== v));
128
+ const event = `look changed: ${Object.entries(stored).map(([k, v]) => `${k}=${v}`).join(', ')}`;
129
+ return await saveAndPush({ req, res, inst, stored, changed, event, extra: { adjusted } });
130
+ } catch (err) {
131
+ log.error('[provisioning] PATCH /provisioning/instances/:id/look', err);
132
+ failFrom(res, err, 'look_update_failed');
133
+ }
134
+ });
135
+
136
+ // PUT …/logo — rank: any authenticated builder (own resource; archon may change any).
137
+ // The image is screened by its bytes, stored, and the hall told its new address.
138
+ router.put('/provisioning/instances/:id/logo', api.requireBuilder, express.json({ limit: LOGO_BODY_LIMIT }), async (req, res) => {
139
+ if (validateOrRespond(req, res, {
140
+ image_b64: { required: true, type: 'string', minLength: 1, maxLength: LOGO_B64_MAX },
141
+ content_type: { required: true, type: 'string', minLength: 1, maxLength: 100 },
142
+ })) return;
143
+ try {
144
+ const inst = await ownInstance(req, res);
145
+ if (!inst) return;
146
+ // Buffer.from drops what it cannot decode, so a mangled body arrives as wrong
147
+ // bytes, and the sniff below refuses those.
148
+ const screened = look.screenLogo(Buffer.from(req.body.image_b64, 'base64'), req.body.content_type);
149
+ if (!screened.ok) return res.fail(`logo_${screened.reason}`, { status: 400, message: screened.message });
150
+ const origin = platformOrigin();
151
+ if (!/^https:\/\//.test(origin)) {
152
+ return res.fail('logo_unavailable', { status: 409, message: 'This platform has no public https address configured, so a hall could not load the logo from it.' });
153
+ }
154
+ const buf = Buffer.from(req.body.image_b64, 'base64');
155
+ await look.upsertLogo(pool, inst.id, screened, buf);
156
+ const url = `${origin}/api/bongos/provisioning/logos/${look.logoFileName(screened.sha, screened.ext)}`;
157
+ const changed = look.logoUrlOf(inst) !== url;
158
+ return await saveAndPush({ req, res, inst, stored: { logo_url: url }, changed, event: 'logo set' });
159
+ } catch (err) {
160
+ log.error('[provisioning] PUT /provisioning/instances/:id/logo', err);
161
+ failFrom(res, err, 'logo_update_failed');
162
+ }
163
+ });
164
+
165
+ // DELETE …/logo — rank: any authenticated builder (own resource; archon may change any).
166
+ router.delete('/provisioning/instances/:id/logo', api.requireBuilder, async (req, res) => {
167
+ if (validateOrRespond(req, res, {})) return;
168
+ try {
169
+ const inst = await ownInstance(req, res);
170
+ if (!inst) return;
171
+ await look.deleteLogo(pool, inst.id);
172
+ const had = !!look.logoUrlOf(inst);
173
+ // An empty string, not a removed key: the merge-only settings write cannot drop a
174
+ // key, and logoUrlOf reads '' as no logo, so the companion env goes out empty.
175
+ return await saveAndPush({ req, res, inst, stored: { logo_url: '' }, changed: had, event: 'logo removed' });
176
+ } catch (err) {
177
+ log.error('[provisioning] DELETE /provisioning/instances/:id/logo', err);
178
+ failFrom(res, err, 'logo_update_failed');
179
+ }
180
+ });
181
+
182
+ // rank: public — a logo is shown on a public hall, and its address is its own sha256,
183
+ // so it names no project and cannot be guessed. Served as an image only: nosniff, a
184
+ // sandboxing CSP, and a cache that never goes stale because a new image is a new name.
185
+ router.get('/provisioning/logos/:file', async (req, res) => {
186
+ const m = look.LOGO_FILE.exec(String(req.params.file || ''));
187
+ if (!m) return res.fail('logo_not_found', 404);
188
+ try {
189
+ const logo = await look.readLogo(pool, m[1], m[2]);
190
+ if (!logo) return res.fail('logo_not_found', 404);
191
+ res.setHeader('Content-Type', look.LOGO_MIME[m[2]]);
192
+ res.setHeader('X-Content-Type-Options', 'nosniff');
193
+ res.setHeader('Content-Security-Policy', "default-src 'none'; sandbox");
194
+ res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin');
195
+ res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
196
+ res.end(logo.bytes);
197
+ } catch (err) {
198
+ log.error('[provisioning] GET /provisioning/logos/:file', err);
199
+ failFrom(res, err, 'logo_read_failed');
200
+ }
201
+ });
202
+
203
+ return router;
204
+ };
@@ -63,6 +63,7 @@ const { callbackPage } = require('./callback-page');
63
63
  // the LOUD body validators (task 1003504) — lifted out when this file hit the size ratchet
64
64
  const { badProjectDetail, badModuleSelection, badProjectType, screenedOut } = require('./body-validators');
65
65
  const { failFrom } = require('../public-refusal'); // every catch: a declared refusal reaches the owner, anything else stays opaque (task 1004126)
66
+ const { settingsPush, failPushConflict } = require('../settings-push'); // a settings save's push + receipt, shared with routes/look.js (task 1004421)
66
67
  // the kernel ports this module provides (moved out at the size ratchet, task 1003578)
67
68
  const { registerProvisioningSeams } = require('../seams');
68
69
  // task 1003208: structured logging (pino via the doorway) — was console.*.
@@ -927,65 +928,10 @@ module.exports = function provisioningRoutes() {
927
928
  // needs no restart and no reachable shape — it is the PLATFORM's own row.
928
929
  await catalogBridge.publishInstanceToCatalog(pool, updated).catch(() => {});
929
930
  }
930
- // The push (task 1003140). The row is now the platform's truth, but a
931
- // RUNNING instance reads its settings from web.env through a pack memoized
932
- // for the process lifetime — so an ACTIVE instance on a reachable shape gets
933
- // a 'settings-apply' intent (web.env patched in place + RESTART, drained by
934
- // the runner; the web tier holds no tokens, ADR 0111 §2). The owner decided
935
- // the restart is acceptable and that the surface must SAY so plainly. Every
936
- // other state stores the value and says when it takes effect. One receipt
937
- // shape on every branch — `restart` = a restart is what applies it,
938
- // `reachable` = the platform can perform that restart — and state-only
939
- // sentences throughout (the F16 rule): no wall-clock promises.
940
- const reachable = provisioning.SETTINGS_APPLY_SHAPES.has(inst.hosting_shape);
941
- const receipt = (fields) => ({ reachable, ...fields });
942
- let push;
943
- if (!changed && !policyResent) {
944
- push = receipt({ queued: false, restart: false, message: 'No change — this project already has these settings.' });
945
- } else if (!policyChanged && !policyResent) {
946
- push = receipt({ queued: false, restart: false, message: 'Saved. The sky, your list and the card draw it this way from here; nothing on your project restarts for it.' });
947
- } else if (!provisioning.SETTINGS_APPLY_FROM.has(inst.status)) {
948
- const offline = inst.status === 'torn_down' || inst.status === 'tearing_down';
949
- push = receipt({ queued: false, restart: false, message: offline
950
- ? 'Saved. This project is offline, so there is nothing to apply it to yet — it takes effect if the project is brought back.'
951
- : inst.status === 'error'
952
- ? 'Saved. This project’s standup stopped partway, so nothing is applying changes right now — it takes effect the next time the project is stood up.'
953
- : 'Saved. It takes effect when the project finishes standing up.' });
954
- } else if (!reachable) {
955
- // A dedicated droplet has no runner write path — refuse the push honestly
956
- // rather than queue one that never lands (the runner refuses it too).
957
- push = receipt({ queued: false, restart: true, message:
958
- 'Saved on the platform. This project runs on a machine the platform doesn’t manage, so it can’t be restarted from here — whoever runs that machine has to apply it there.' });
959
- } else {
960
- let r = null;
961
- try { r = await provisioning.enqueueSettingsApply(pool, id, actor); }
962
- catch (err) {
963
- // The value IS saved — say so; a 500 here would deny a durable write.
964
- log.error('[provisioning] PATCH /provisioning/instances/:id/settings enqueue', err);
965
- // The remedy this sentence names has to WORK (task 1003524): nothing is
966
- // going to carry the saved value, so record the debt. Best-effort — a failed
967
- // marker write must never turn a durable settings write into a 500.
968
- await provisioning.recordSettingsPushOwed(pool, id, true).catch(() => {});
969
- push = receipt({ queued: false, restart: true, message: 'Saved, but the restart could not be queued just now — send it again to apply it.' });
970
- }
971
- if (r && r.conflict) {
972
- // Saved, but the slot is held by work that will not carry this change, so
973
- // "send it again once that settles" has to re-queue when they do (task 1003524).
974
- await provisioning.recordSettingsPushOwed(pool, id, true).catch(() => {});
975
- return res.fail('intent_conflict', { status: 409,
976
- message: `The change was saved, but a '${r.intent.action}' run is currently ${r.intent.state} for this project — send it again once that settles to apply it.`,
977
- details: { action: r.intent.action, state: r.intent.state, recorded: true } });
978
- }
979
- if (r) {
980
- // A push is now in hand — fresh, or a PENDING one that has not read the row
981
- // yet — so either way it carries the current settings. Only written when
982
- // there IS a debt, so the hot path stays a single UPDATE.
983
- if (pushOwed) await provisioning.recordSettingsPushOwed(pool, id, false).catch(() => {});
984
- push = receipt({ queued: r.created, restart: true, message: r.created
985
- ? 'Saved. Your project will restart to pick this up — until then it keeps its current setting.'
986
- : 'Saved. A restart is already queued for this project and will carry this change.' });
987
- }
988
- }
931
+ // The push and its receipt (task 1003140) — settings-push.js, shared with PATCH …/look.
932
+ const { push, conflict } = await settingsPush({ pool, provisioning, inst, id, actor, changed, policyChanged,
933
+ policyResent, pushOwed, log, logTag: 'PATCH /provisioning/instances/:id/settings' });
934
+ if (conflict) return failPushConflict(res, conflict);
989
935
  res.json({ ok: true, saved: provisioning.effectiveSettings(updated), planet: provisioning.planetChoice(updated), push });
990
936
  } catch (err) {
991
937
  log.error('[provisioning] PATCH /provisioning/instances/:id/settings', err);
@@ -0,0 +1,76 @@
1
+ // modules/provisioning/settings-push.js — after a settings save: queue the push that
2
+ // carries it to a running project, and say what happens next (task 1003140; shared
3
+ // with the look route by task 1004421, ADR 0358).
4
+ //
5
+ // The row is the platform's truth, but a RUNNING instance reads its settings from
6
+ // web.env through a pack memoized for the process lifetime — so an ACTIVE instance on
7
+ // a reachable shape gets a 'settings-apply' intent (web.env patched in place + RESTART,
8
+ // drained by the runner; the web tier holds no tokens, ADR 0111 §2). The owner decided
9
+ // the restart is acceptable and that the surface must SAY so plainly. Every other
10
+ // state stores the value and says when it takes effect. One receipt shape on every
11
+ // branch — `restart` = a restart is what applies it, `reachable` = the platform can
12
+ // perform that restart — and state-only sentences throughout (the F16 rule): no
13
+ // wall-clock promises.
14
+ //
15
+ // Moved out of routes/provisioning.js (at its size ratchet) so PATCH …/settings and
16
+ // PATCH …/look answer from one copy. Returns { push } or { conflict } — the 409 the
17
+ // caller sends when the open slot is held by other work (the change IS saved).
18
+ 'use strict';
19
+
20
+ async function settingsPush({ pool, provisioning, inst, id, actor, changed, policyChanged, policyResent, pushOwed, log, logTag }) {
21
+ const reachable = provisioning.SETTINGS_APPLY_SHAPES.has(inst.hosting_shape);
22
+ const receipt = (fields) => ({ reachable, ...fields });
23
+ if (!changed && !policyResent) {
24
+ return { push: receipt({ queued: false, restart: false, message: 'No change — this project already has these settings.' }) };
25
+ }
26
+ if (!policyChanged && !policyResent) {
27
+ return { push: receipt({ queued: false, restart: false, message: 'Saved. The sky, your list and the card draw it this way from here; nothing on your project restarts for it.' }) };
28
+ }
29
+ if (!provisioning.SETTINGS_APPLY_FROM.has(inst.status)) {
30
+ const offline = inst.status === 'torn_down' || inst.status === 'tearing_down';
31
+ return { push: receipt({ queued: false, restart: false, message: offline
32
+ ? 'Saved. This project is offline, so there is nothing to apply it to yet — it takes effect if the project is brought back.'
33
+ : inst.status === 'error'
34
+ ? 'Saved. This project’s standup stopped partway, so nothing is applying changes right now — it takes effect the next time the project is stood up.'
35
+ : 'Saved. It takes effect when the project finishes standing up.' }) };
36
+ }
37
+ if (!reachable) {
38
+ // A dedicated droplet has no runner write path — refuse the push honestly
39
+ // rather than queue one that never lands (the runner refuses it too).
40
+ return { push: receipt({ queued: false, restart: true, message:
41
+ 'Saved on the platform. This project runs on a machine the platform doesn’t manage, so it can’t be restarted from here — whoever runs that machine has to apply it there.' }) };
42
+ }
43
+ let r = null;
44
+ try { r = await provisioning.enqueueSettingsApply(pool, id, actor); }
45
+ catch (err) {
46
+ // The value IS saved — say so; a 500 here would deny a durable write.
47
+ log.error(`[provisioning] ${logTag} enqueue`, err);
48
+ // The remedy this sentence names has to WORK (task 1003524): nothing is
49
+ // going to carry the saved value, so record the debt. Best-effort — a failed
50
+ // marker write must never turn a durable settings write into a 500.
51
+ await provisioning.recordSettingsPushOwed(pool, id, true).catch(() => {});
52
+ return { push: receipt({ queued: false, restart: true, message: 'Saved, but the restart could not be queued just now — send it again to apply it.' }) };
53
+ }
54
+ if (r && r.conflict) {
55
+ // Saved, but the slot is held by work that will not carry this change, so
56
+ // "send it again once that settles" has to re-queue when they do (task 1003524).
57
+ await provisioning.recordSettingsPushOwed(pool, id, true).catch(() => {});
58
+ return { conflict: r };
59
+ }
60
+ // A push is now in hand — fresh, or a PENDING one that has not read the row
61
+ // yet — so either way it carries the current settings. Only written when
62
+ // there IS a debt, so the hot path stays a single UPDATE.
63
+ if (pushOwed) await provisioning.recordSettingsPushOwed(pool, id, false).catch(() => {});
64
+ return { push: receipt({ queued: r.created, restart: true, message: r.created
65
+ ? 'Saved. Your project will restart to pick this up — until then it keeps its current setting.'
66
+ : 'Saved. A restart is already queued for this project and will carry this change.' }) };
67
+ }
68
+
69
+ // The 409 a conflicted push answers with — one wording for both routes.
70
+ function failPushConflict(res, r) {
71
+ return res.fail('intent_conflict', { status: 409,
72
+ message: `The change was saved, but a '${r.intent.action}' run is currently ${r.intent.state} for this project — send it again once that settles to apply it.`,
73
+ details: { action: r.intent.action, state: r.intent.state, recorded: true } });
74
+ }
75
+
76
+ module.exports = { settingsPush, failPushConflict };
@@ -1603,7 +1603,7 @@ p.sec-p{font-size:var(--fs-lede);font-weight:500;color:var(--ink-soft);line-heig
1603
1603
  /* the six-step strip as the rail: six hairlines, a 7px dot, one lit step on the
1604
1604
  accent with its halo, the number in the display voice, the name in the label
1605
1605
  face; a step already answered (role=button) stays a way back */
1606
- #view-new .steps{display:grid;grid-template-columns:repeat(8,1fr);gap:0 10px;align-items:start;margin:0 0 40px;}
1606
+ #view-new .steps{display:grid;grid-template-columns:repeat(9,1fr);gap:0 10px;align-items:start;margin:0 0 40px;}
1607
1607
  #view-new .steps li{display:block;position:relative;border-top:1px solid var(--rule);padding:12px 0 0;gap:0;}
1608
1608
  #view-new .steps li::before{content:"";position:absolute;top:-4px;left:0;right:auto;width:7px;height:7px;margin:0;border-radius:50%;border:1px solid var(--rule);background:var(--bg);}
1609
1609
  #view-new .steps .snum{display:block;width:auto;height:auto;border:0;border-radius:0;background:none;font-family:var(--font-display);font-weight:300;font-size:22px;letter-spacing:-.02em;line-height:1.1;color:var(--ink-faint);margin-bottom:2px;}