@bongos/core 1.19.348 → 1.19.350
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 +82 -42
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +8 -0
- package/clients/bongos-client/index.cjs +8 -0
- package/clients/bongos-client/index.d.ts +11 -0
- package/clients/bongos-client/index.mjs +8 -0
- package/docs/adr/0184-a-task-visual-inherits-its-summary-audience.md +49 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +208 -3
- package/docs/api-reference.md +10 -2
- package/docs/architecture.md +6 -1
- package/docs/copy-inventory.md +38 -38
- package/docs/copy-registry.json +40 -40
- package/docs/file-map.md +2 -1
- package/migrations/core_223_task_visual.sql +33 -0
- package/modules/dev-box/app/src/vendor/bongos-client.cjs +8 -0
- package/modules/hall-ui/public/hall-kit.js +33 -0
- package/modules/hall-ui/public/hall-render.js +1 -0
- package/modules/hall-ui/public/profile.html +4 -1
- package/modules/hall-ui/public/profile.js +7 -1
- package/modules/hall-ui/public/style.css +29 -0
- package/modules/hall-ui/public/task.js +30 -0
- package/modules/lifecycle/db-analytics.js +3 -1
- package/modules/lifecycle/github-push.js +51 -4
- package/modules/lifecycle/module.json +2 -1
- package/modules/lifecycle/routes/tasks.js +22 -2
- package/modules/lifecycle/routes/visuals.js +216 -0
- package/modules/lifecycle/task-visual-db.js +46 -0
- package/modules/lifecycle/task-visuals.js +270 -0
- package/modules/status-ui/public/status.js +33 -0
- package/modules/status-ui/public/style.css +14 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/ship-flow.js +11 -1
- package/scripts/gds/ship-land.js +24 -2
- package/scripts/gds/ship-visual.js +68 -0
- package/scripts/gds/task.js +62 -0
- package/src/bongos/routes/public.js +8 -0
- package/src/bongos/routes.js +5 -1
- package/src/module-api.js +5 -1
- package/tests/ship_publish_error.mjs +86 -2
- package/tests/task_visual_route_gate.mjs +151 -0
- package/tests/task_visuals.mjs +200 -0
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Task-visual storage — the OPTIONAL image a builder attaches to a shipped task
|
|
4
|
+
// (task 1003109, goal 1000072 "Human legibility", from owner idea 1000400).
|
|
5
|
+
//
|
|
6
|
+
// WHY. `value_summary` already gives a reader the plain-language "what this
|
|
7
|
+
// means" line at ship time. What a non-engineer still could not get is a PICTURE:
|
|
8
|
+
// a screenshot of the surface that changed, a before/after, a diagram. Today the
|
|
9
|
+
// only visual path is a Discord post, which sits outside the ledger and expires.
|
|
10
|
+
// This is that same claim in another medium — so it is stored beside the summary,
|
|
11
|
+
// rendered exactly where the summary is rendered, and is ALWAYS optional: a task
|
|
12
|
+
// with no visual is complete, and nothing on the ship path blocks on its absence.
|
|
13
|
+
//
|
|
14
|
+
// This module is the pure-ish storage core: where files live, how a filename is
|
|
15
|
+
// validated (path-traversal-safe), which task a stored name belongs to, and how
|
|
16
|
+
// one uploaded buffer is screened + written. No express, no db — it unit-tests
|
|
17
|
+
// with a temp dir and a Buffer.
|
|
18
|
+
//
|
|
19
|
+
// DELIBERATE DUPLICATION with modules/discord/bug-attachments.js (the ADR 0091 §2
|
|
20
|
+
// "boundary-forced duplication is allowed + documented" rule): that lib is the
|
|
21
|
+
// worked example this one follows, but it lives in a SIBLING module and ADR 0083
|
|
22
|
+
// forbids requiring one module from another. So the extension allowlist, the
|
|
23
|
+
// magic-byte sniff and the resolveSafe belt-and-braces are re-stated here rather
|
|
24
|
+
// than imported. Same shapes on both sides; keep them in sync. The one real
|
|
25
|
+
// difference is the INTAKE: bug-attachments DOWNLOADS from the Discord CDN (hence
|
|
26
|
+
// its host allowlist + SSRF guard), while a visual is UPLOADED as bytes by the
|
|
27
|
+
// shipping builder — so there is no outbound fetch here to defend.
|
|
28
|
+
//
|
|
29
|
+
// ANTI-ABUSE (the "must not become an arbitrary-file host" posture): an
|
|
30
|
+
// allowlisted extension only, a magic-byte sniff so a mislabeled content-type
|
|
31
|
+
// cannot smuggle a non-image onto disk, a per-image size cap, and a stored
|
|
32
|
+
// filename shape the serve route validates exactly. The serve route adds
|
|
33
|
+
// `nosniff` + `Content-Disposition: inline` so a stored byte-string can never be
|
|
34
|
+
// interpreted as script by a browser on our own origin.
|
|
35
|
+
|
|
36
|
+
const path = require('node:path');
|
|
37
|
+
const fs = require('node:fs/promises');
|
|
38
|
+
const fsSync = require('node:fs'); // readImageFile is the CLI's synchronous intake.
|
|
39
|
+
const crypto = require('node:crypto');
|
|
40
|
+
|
|
41
|
+
// ext ⇄ content-type for the formats we accept. SVG is deliberately ABSENT: it is
|
|
42
|
+
// an executable document (inline <script>), and these are served from our own
|
|
43
|
+
// origin.
|
|
44
|
+
const EXT_BY_TYPE = {
|
|
45
|
+
'image/png': 'png',
|
|
46
|
+
'image/jpeg': 'jpg',
|
|
47
|
+
'image/gif': 'gif',
|
|
48
|
+
'image/webp': 'webp',
|
|
49
|
+
};
|
|
50
|
+
const TYPE_BY_EXT = {
|
|
51
|
+
png: 'image/png',
|
|
52
|
+
jpg: 'image/jpeg',
|
|
53
|
+
gif: 'image/gif',
|
|
54
|
+
webp: 'image/webp',
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const MAX_BYTES = 4 * 1024 * 1024; // 4 MB — a screenshot, not an asset library.
|
|
58
|
+
const MAX_ALT_LENGTH = 300; // the caption/alt text stored beside it.
|
|
59
|
+
|
|
60
|
+
// Stored filenames are SERVER-generated, never user-supplied: a fixed shape we
|
|
61
|
+
// can validate exactly, carrying the task id so the serve route can resolve the
|
|
62
|
+
// image's privacy posture from the task it belongs to without a second lookup
|
|
63
|
+
// table. This regex is the whole path-traversal defense — the serve route only
|
|
64
|
+
// ever opens a name that matches it.
|
|
65
|
+
const NAME_RE = /^task-(\d{1,12})-[0-9a-f]{16}\.(png|jpg|gif|webp)$/;
|
|
66
|
+
|
|
67
|
+
// Where visuals live. Outside the git tree (a `var/` dir) so a deploy's
|
|
68
|
+
// `git reset --hard` / pull never wipes them; gitignored. Env override for tests.
|
|
69
|
+
function visualsDir() {
|
|
70
|
+
return process.env.TASK_VISUALS_DIR
|
|
71
|
+
|| path.join(__dirname, '..', '..', 'var', 'task-visuals');
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// PURE — is this a well-formed, server-generated visual name?
|
|
75
|
+
function isSafeName(name) {
|
|
76
|
+
return typeof name === 'string' && NAME_RE.test(name);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// PURE — which task does a stored name belong to? Returns the numeric id, or
|
|
80
|
+
// null for any name that isn't well-formed. The serve route's privacy gate reads
|
|
81
|
+
// the task's status through this, so an unparseable name fails CLOSED.
|
|
82
|
+
function taskIdFromName(name) {
|
|
83
|
+
if (typeof name !== 'string') return null;
|
|
84
|
+
const m = NAME_RE.exec(name);
|
|
85
|
+
return m ? Number(m[1]) : null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// PURE — resolve a name to an absolute path INSIDE visualsDir, or null if the
|
|
89
|
+
// name is unsafe or would escape the dir. Belt (regex) and braces (prefix check
|
|
90
|
+
// on the resolved path).
|
|
91
|
+
function resolveSafe(name, { dir = visualsDir() } = {}) {
|
|
92
|
+
if (!isSafeName(name)) return null;
|
|
93
|
+
const root = path.resolve(dir);
|
|
94
|
+
const p = path.resolve(root, name);
|
|
95
|
+
if (p !== path.join(root, name)) return null;
|
|
96
|
+
if (!p.startsWith(root + path.sep)) return null;
|
|
97
|
+
return p;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// PURE — the content-type to serve a stored file with (from its extension).
|
|
101
|
+
function contentTypeForName(name) {
|
|
102
|
+
const ext = String(name).split('.').pop().toLowerCase();
|
|
103
|
+
return TYPE_BY_EXT[ext] || 'application/octet-stream';
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// PURE — confirm the bytes really are the claimed image format (magic bytes).
|
|
107
|
+
function sniffMatches(buf, ext) {
|
|
108
|
+
if (!buf || buf.length < 12) return false;
|
|
109
|
+
switch (ext) {
|
|
110
|
+
case 'png':
|
|
111
|
+
return buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4e && buf[3] === 0x47;
|
|
112
|
+
case 'jpg':
|
|
113
|
+
return buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff;
|
|
114
|
+
case 'gif':
|
|
115
|
+
return buf[0] === 0x47 && buf[1] === 0x49 && buf[2] === 0x46 && buf[3] === 0x38;
|
|
116
|
+
case 'webp':
|
|
117
|
+
return (
|
|
118
|
+
buf[0] === 0x52 && buf[1] === 0x49 && buf[2] === 0x46 && buf[3] === 0x46 && // 'RIFF'
|
|
119
|
+
buf[8] === 0x57 && buf[9] === 0x45 && buf[10] === 0x42 && buf[11] === 0x50 // 'WEBP'
|
|
120
|
+
);
|
|
121
|
+
default:
|
|
122
|
+
return false;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// PURE — screen one uploaded buffer against the declared content-type. Returns
|
|
127
|
+
// { ok:true, ext } or { ok:false, reason } — never throws. Exported separately
|
|
128
|
+
// from the write so every refusal is pinned by a DB-free, fs-free unit test.
|
|
129
|
+
function screenImage(buf, contentType) {
|
|
130
|
+
const rawType = String(contentType || '').split(';')[0].trim().toLowerCase();
|
|
131
|
+
const ext = EXT_BY_TYPE[rawType];
|
|
132
|
+
if (!ext) return { ok: false, reason: 'bad_type', type: rawType };
|
|
133
|
+
if (!buf || buf.length === 0) return { ok: false, reason: 'empty' };
|
|
134
|
+
if (buf.length > MAX_BYTES) return { ok: false, reason: 'too_big', bytes: buf.length };
|
|
135
|
+
if (!sniffMatches(buf, ext)) return { ok: false, reason: 'sniff_mismatch' };
|
|
136
|
+
return { ok: true, ext };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// PURE — THE AUDIENCE PREDICATE. A visual has no audience of its own: it is the
|
|
140
|
+
// same claim as the task's value_summary in another medium, so it is viewable by
|
|
141
|
+
// whoever may already read that summary. Public work is `shipped`|`confirmed` —
|
|
142
|
+
// byte-for-byte the gate src/bongos/routes/public.js:projectPublicTask uses to
|
|
143
|
+
// decide whether a title/value_summary reaches the open web.
|
|
144
|
+
//
|
|
145
|
+
// The ONE deliberate narrowing: a security_sensitive task's image stays
|
|
146
|
+
// builders-only even once shipped. A summary line is a sentence its author chose;
|
|
147
|
+
// a screenshot shows whatever was on the screen, and on a security task that is
|
|
148
|
+
// the wrong thing to publish by default. Anything not public here is still served
|
|
149
|
+
// to any authenticated builder, so nothing is HIDDEN by this — only un-published.
|
|
150
|
+
//
|
|
151
|
+
// MIRRORED IN SQL: modules/lifecycle/db.js:recentShippedTasks nulls visual_url for
|
|
152
|
+
// a security_sensitive row on the public feed, so the feed and this gate agree
|
|
153
|
+
// (the feed never learns the flag itself). Change one, change the other.
|
|
154
|
+
function isPubliclyViewable(task) {
|
|
155
|
+
if (!task) return false;
|
|
156
|
+
if (task.security_sensitive === true) return false;
|
|
157
|
+
return task.status === 'shipped' || task.status === 'confirmed';
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// PURE — the public URL a stored file is served at. One definition, so the write
|
|
161
|
+
// path and the docs cannot drift from the route.
|
|
162
|
+
//
|
|
163
|
+
// The CANONICAL prefix (src/bongos/api-prefix.js) is hard-coded rather than
|
|
164
|
+
// imported: this lib is deliberately core-free so it unit-tests with a Buffer and
|
|
165
|
+
// a temp dir, and the doorway does not publish the prefix. It is safe to hard-code
|
|
166
|
+
// because BOTH prefixes are permanent — '/api/gds' is a forever alias for shipped
|
|
167
|
+
// Dev Box binaries and the off-box status mirror — so a URL stored under either
|
|
168
|
+
// keeps resolving, and every renderer's guard accepts both.
|
|
169
|
+
const SERVE_PREFIX = '/api/bongos/task-visuals';
|
|
170
|
+
function urlForName(name) {
|
|
171
|
+
return `${SERVE_PREFIX}/${name}`;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// PURE — normalize the caption/alt text: trimmed, length-bounded, newlines
|
|
175
|
+
// flattened (it renders into an `alt` attribute). Empty → null.
|
|
176
|
+
function normalizeAlt(alt) {
|
|
177
|
+
const s = String(alt == null ? '' : alt).replace(/\s+/g, ' ').trim().slice(0, MAX_ALT_LENGTH);
|
|
178
|
+
return s || null;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Read one image OFF DISK and screen it — the CLI intake (`ship.js --visual`,
|
|
182
|
+
// `bongos task visual`). It lives here, beside the screening it wraps, so the two
|
|
183
|
+
// CLI call sites cannot drift from each other or from the upload route: what a
|
|
184
|
+
// terminal accepts is exactly what the server accepts. Returns
|
|
185
|
+
// { ok:true, buf, contentType } or { ok:false, message } — the message is written
|
|
186
|
+
// for a person reading a terminal, not for a log.
|
|
187
|
+
function readImageFile(file) {
|
|
188
|
+
const resolved = path.resolve(String(file || ''));
|
|
189
|
+
let buf;
|
|
190
|
+
try {
|
|
191
|
+
if (!fsSync.statSync(resolved).isFile()) return { ok: false, message: 'that path is not a file.' };
|
|
192
|
+
buf = fsSync.readFileSync(resolved);
|
|
193
|
+
} catch (err) {
|
|
194
|
+
if (err && err.code === 'ENOENT') return { ok: false, message: 'no such file.' };
|
|
195
|
+
return { ok: false, message: err && err.message ? err.message : 'could not be read.' };
|
|
196
|
+
}
|
|
197
|
+
const rawExt = path.extname(resolved).slice(1).toLowerCase();
|
|
198
|
+
const ext = rawExt === 'jpeg' ? 'jpg' : rawExt;
|
|
199
|
+
const contentType = TYPE_BY_EXT[ext];
|
|
200
|
+
if (!contentType) {
|
|
201
|
+
return { ok: false, message: `unsupported file type "${rawExt || '(none)'}". Accepted: ${Object.keys(TYPE_BY_EXT).join(', ')}.` };
|
|
202
|
+
}
|
|
203
|
+
const screened = screenImage(buf, contentType);
|
|
204
|
+
if (!screened.ok) {
|
|
205
|
+
const why = {
|
|
206
|
+
too_big: `it is ${Math.round(buf.length / 1024)} KB — the cap is ${Math.round(MAX_BYTES / 1024 / 1024)} MB.`,
|
|
207
|
+
empty: 'the file is empty.',
|
|
208
|
+
sniff_mismatch: `the bytes are not really a ${ext} image (the extension and the contents disagree).`,
|
|
209
|
+
bad_type: `unsupported file type "${rawExt}".`,
|
|
210
|
+
}[screened.reason] || `it was refused (${screened.reason}).`;
|
|
211
|
+
return { ok: false, message: why };
|
|
212
|
+
}
|
|
213
|
+
return { ok: true, buf, contentType };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Screen + write one uploaded image. Returns { ok:true, file, url, bytes } or
|
|
217
|
+
// { ok:false, reason } on a refusal (never throws on a refusal; an unexpected fs
|
|
218
|
+
// error propagates for the caller to wrap).
|
|
219
|
+
async function persistImageBuffer(buf, { taskId, contentType, dir = visualsDir() } = {}) {
|
|
220
|
+
const id = Number(taskId);
|
|
221
|
+
if (!Number.isInteger(id) || id <= 0 || String(id).length > 12) {
|
|
222
|
+
return { ok: false, reason: 'bad_task_id' };
|
|
223
|
+
}
|
|
224
|
+
const screened = screenImage(buf, contentType);
|
|
225
|
+
if (!screened.ok) return screened;
|
|
226
|
+
const file = `task-${id}-${crypto.randomBytes(8).toString('hex')}.${screened.ext}`;
|
|
227
|
+
await fs.mkdir(dir, { recursive: true });
|
|
228
|
+
await fs.writeFile(path.join(dir, file), buf, { mode: 0o640 });
|
|
229
|
+
return { ok: true, file, url: urlForName(file), bytes: buf.length };
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Best-effort delete of a stored file, by name or by its served URL. Used when a
|
|
233
|
+
// visual is REPLACED or removed, so a superseded image doesn't linger on disk.
|
|
234
|
+
// Never throws — an already-absent file is a success.
|
|
235
|
+
async function deleteStored(nameOrUrl, { dir = visualsDir() } = {}) {
|
|
236
|
+
const name = String(nameOrUrl == null ? '' : nameOrUrl).split('/').pop();
|
|
237
|
+
const p = resolveSafe(name, { dir });
|
|
238
|
+
if (!p) return false;
|
|
239
|
+
try {
|
|
240
|
+
await fs.unlink(p);
|
|
241
|
+
return true;
|
|
242
|
+
} catch (err) {
|
|
243
|
+
// An already-absent file IS the desired state. Any other failure leaves an
|
|
244
|
+
// unreferenced file on disk, which is inert — the caller is best-effort and
|
|
245
|
+
// the boolean is the whole report, so this stays a silent, logger-free lib.
|
|
246
|
+
if (err && err.code === 'ENOENT') return true;
|
|
247
|
+
return false;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
module.exports = {
|
|
252
|
+
EXT_BY_TYPE,
|
|
253
|
+
TYPE_BY_EXT,
|
|
254
|
+
MAX_BYTES,
|
|
255
|
+
MAX_ALT_LENGTH,
|
|
256
|
+
visualsDir,
|
|
257
|
+
isSafeName,
|
|
258
|
+
taskIdFromName,
|
|
259
|
+
resolveSafe,
|
|
260
|
+
contentTypeForName,
|
|
261
|
+
sniffMatches,
|
|
262
|
+
screenImage,
|
|
263
|
+
readImageFile,
|
|
264
|
+
normalizeAlt,
|
|
265
|
+
urlForName,
|
|
266
|
+
SERVE_PREFIX,
|
|
267
|
+
isPubliclyViewable,
|
|
268
|
+
persistImageBuffer,
|
|
269
|
+
deleteStored,
|
|
270
|
+
};
|
|
@@ -912,6 +912,32 @@ let _shippedPage = 0;
|
|
|
912
912
|
// in index.html is also pre-selected to "30" so the two stay aligned.
|
|
913
913
|
let _shippedRange = '30';
|
|
914
914
|
|
|
915
|
+
// task 1003109: build the thumbnail node for one shipped row, or null when the
|
|
916
|
+
// row has no visual. Built with DOM nodes (not innerHTML) like the rest of this
|
|
917
|
+
// file, and the URL is matched against the exact shape the server mints before it
|
|
918
|
+
// reaches an href/src — this page renders UNAUTHENTICATED data on a public host,
|
|
919
|
+
// so it treats its own API's strings as data, not as markup.
|
|
920
|
+
const VISUAL_URL_RE = /^\/api\/(gds|bongos)\/task-visuals\/task-\d+-[0-9a-f]{16}\.(png|jpg|gif|webp)$/;
|
|
921
|
+
function shippedVisualEl(t) {
|
|
922
|
+
const url = String((t && t.visual_url) || '');
|
|
923
|
+
if (!VISUAL_URL_RE.test(url)) return null;
|
|
924
|
+
const alt = String((t && t.visual_alt) || `Visual for task ${(t && t.id) || ''}`).trim();
|
|
925
|
+
const a = document.createElement('a');
|
|
926
|
+
// `API` is '' on the droplet (same-origin) and the droplet's origin on the
|
|
927
|
+
// off-box GitHub Pages mirror — the same prefix every fetch on this page uses.
|
|
928
|
+
a.href = `${API}${url}`;
|
|
929
|
+
a.target = '_blank';
|
|
930
|
+
a.rel = 'noopener';
|
|
931
|
+
a.title = alt;
|
|
932
|
+
const img = document.createElement('img');
|
|
933
|
+
img.className = 'shipped__visual';
|
|
934
|
+
img.src = `${API}${url}`;
|
|
935
|
+
img.alt = alt;
|
|
936
|
+
img.loading = 'lazy';
|
|
937
|
+
a.appendChild(img);
|
|
938
|
+
return a;
|
|
939
|
+
}
|
|
940
|
+
|
|
915
941
|
function renderShippedPage() {
|
|
916
942
|
clear(els.shipped);
|
|
917
943
|
const items = _shippedItems;
|
|
@@ -949,11 +975,18 @@ function renderShippedPage() {
|
|
|
949
975
|
ver.textContent = t.version_id;
|
|
950
976
|
title.appendChild(ver);
|
|
951
977
|
title.append(t.value_summary || t.title);
|
|
978
|
+
// task 1003109 (goal 1000072): the optional ship-time VISUAL, beside the
|
|
979
|
+
// summary it restates. This surface is the whole reason the feature exists —
|
|
980
|
+
// an unauthenticated reader who does not read code, seeing what shipped. Only
|
|
981
|
+
// already-public, non-security-sensitive work carries one here (the server
|
|
982
|
+
// nulls the rest out of this feed), and a task without one draws nothing.
|
|
983
|
+
const thumb = shippedVisualEl(t);
|
|
952
984
|
const meta = document.createElement('p');
|
|
953
985
|
meta.className = 'shipped__meta';
|
|
954
986
|
const by = t.display_name || t.github_login;
|
|
955
987
|
meta.textContent = `${by ? `inscribed by ${by} · ` : ''}${fmtRelativeTime(t.shipped_at)}`;
|
|
956
988
|
li.appendChild(title);
|
|
989
|
+
if (thumb) li.appendChild(thumb);
|
|
957
990
|
li.appendChild(meta);
|
|
958
991
|
els.shipped.appendChild(li);
|
|
959
992
|
}
|
|
@@ -598,6 +598,20 @@ main {
|
|
|
598
598
|
margin: 0;
|
|
599
599
|
}
|
|
600
600
|
|
|
601
|
+
/* task 1003109: the optional ship-time visual on a public shipped row. Small on
|
|
602
|
+
purpose — this is a scannable feed, and the thumbnail links to the full-size
|
|
603
|
+
image for anyone who wants to look properly. Absent on most rows; nothing is
|
|
604
|
+
drawn when a task has no visual. */
|
|
605
|
+
.shipped__visual {
|
|
606
|
+
display: block;
|
|
607
|
+
width: 40px;
|
|
608
|
+
height: 40px;
|
|
609
|
+
object-fit: cover;
|
|
610
|
+
margin: 0 0 6px;
|
|
611
|
+
border: 1px solid var(--rule, rgba(0, 0, 0, 0.15));
|
|
612
|
+
border-radius: 3px;
|
|
613
|
+
}
|
|
614
|
+
|
|
601
615
|
.shipped__version {
|
|
602
616
|
font-family: var(--font-display);
|
|
603
617
|
font-weight: 500;
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.350",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.19.
|
|
9
|
+
"version": "1.19.350",
|
|
10
10
|
"license": "AGPL-3.0-or-later",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"express": "^4.21.2",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.350",
|
|
4
4
|
"description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
|
|
5
5
|
"license": "AGPL-3.0-or-later",
|
|
6
6
|
"main": "src/platform-server.js",
|
package/scripts/gds/ship-flow.js
CHANGED
|
@@ -15,6 +15,8 @@ const { scopeForModule, isModuleKey } = require('../../src/bongos/module-scope-m
|
|
|
15
15
|
const { matchOne } = require('../../src/bongos/path-match');
|
|
16
16
|
const { runShipGate } = require('./sandbox-stage');
|
|
17
17
|
const { captureSurpriseLearning, promptSurprise } = require('./ship-surprise.js');
|
|
18
|
+
// task 1003109: the optional `--visual` leg of a ship (goal 1000072).
|
|
19
|
+
const shipVisual = require('./ship-visual.js');
|
|
18
20
|
const { gitOk } = require('./ship-git.js');
|
|
19
21
|
const { autoLinkifyChangedDocs, mergeMainIntoBranch, preflight } = require('./ship-preflight-steps.js');
|
|
20
22
|
const { applyBaseOverride, computeChangedFiles, computeGraderBaseline, runSmokeAsync } = require('./ship-grade.js');
|
|
@@ -35,7 +37,7 @@ async function shipMain() {
|
|
|
35
37
|
const args = process.argv.slice(2);
|
|
36
38
|
const taskId = Number(args[0]);
|
|
37
39
|
if (!Number.isFinite(taskId)) {
|
|
38
|
-
console.error('usage: node scripts/gds/ship.js <task-id> (--notes-file <path|-> | --notes "...") (--summary-file <path|-> | --summary "...") [--surprise "text" | --surprise none | --no-surprise] [--no-merge] [--db-only] [--verified] [--allow-empty] [--allow-smoke-fail] [--base <ref>]');
|
|
40
|
+
console.error('usage: node scripts/gds/ship.js <task-id> (--notes-file <path|-> | --notes "...") (--summary-file <path|-> | --summary "...") [--visual <image>] [--visual-alt "caption"] [--surprise "text" | --surprise none | --no-surprise] [--no-merge] [--db-only] [--verified] [--allow-empty] [--allow-smoke-fail] [--base <ref>]');
|
|
39
41
|
console.error(' prefer the -file forms for prose: a shell command-substitutes backticks in an inline value before node sees it (learning 1000197).');
|
|
40
42
|
process.exit(2);
|
|
41
43
|
}
|
|
@@ -94,6 +96,10 @@ async function shipMain() {
|
|
|
94
96
|
console.log(`--base: grading ${res.sha.slice(0, 9)}...HEAD — the task's real committed diff (work already on main). Graded via the rank-open path; no --allow-empty needed.`);
|
|
95
97
|
}
|
|
96
98
|
|
|
99
|
+
// task 1003109: screen --visual BEFORE any heavy work, so bad input aborts with
|
|
100
|
+
// the claim STILL ACTIVE (the --base posture above). null when none was asked for.
|
|
101
|
+
const visual = shipVisual.fromArgs(args);
|
|
102
|
+
|
|
97
103
|
const me = noteServerReward(await (await cliClient()).me.getMe());
|
|
98
104
|
if (!me.ok) {
|
|
99
105
|
console.error('failed to load profile:', me.data);
|
|
@@ -456,6 +462,10 @@ async function shipMain() {
|
|
|
456
462
|
|
|
457
463
|
const taskStatus = r.data.taskStatus || 'shipped (legacy response)';
|
|
458
464
|
console.log(`Task #${r.data.taskId} is now: ${taskStatus}`);
|
|
465
|
+
// task 1003109: attach the optional visual now the claim has resolved (the upload
|
|
466
|
+
// gate is "the task's last claim holder" — still us). Best-effort: it prints its
|
|
467
|
+
// own warning, and a failed picture never fails a successful ship.
|
|
468
|
+
await shipVisual.upload({ taskId, visual, client: await cliClient(), errorLine: apiErrorLine });
|
|
459
469
|
// Claim CONSUMED, task at 'completed' — a crash from here needs fatalRecoveryLines.
|
|
460
470
|
markShipProgress('resolved', taskId);
|
|
461
471
|
|
package/scripts/gds/ship-land.js
CHANGED
|
@@ -89,12 +89,23 @@ function formatPublishFailure(data, status, branch, opts = {}) {
|
|
|
89
89
|
const envelopeDetails = (data && data.error && typeof data.error === 'object' && data.error.details) || null;
|
|
90
90
|
const details = envelopeDetails || data || {};
|
|
91
91
|
const label = message || code || (data && data.message) || `HTTP ${status}`;
|
|
92
|
-
//
|
|
92
|
+
// Flattened ONLY for the matchers below and the one-line summary. The readable
|
|
93
|
+
// copy is printed verbatim underneath (task 1003222): the server no longer clips
|
|
94
|
+
// stderr, and git's useful line is the LAST one, so collapsing a 20-line push
|
|
95
|
+
// failure into a single run-on sentence hides the very thing that was un-hidden.
|
|
93
96
|
const reason = details.reason ? String(details.reason).replace(/\s+/g, ' ').trim() : '';
|
|
94
97
|
const parts = [];
|
|
95
98
|
if (details.step) parts.push(`step: ${details.step}`);
|
|
96
|
-
if (
|
|
99
|
+
if (details.git_exit_code != null) parts.push(`git exit ${details.git_exit_code}`);
|
|
100
|
+
if (details.github_status != null) parts.push(`GitHub HTTP ${details.github_status}`);
|
|
101
|
+
if (details.timed_out) parts.push('TIMED OUT');
|
|
102
|
+
// The one-line summary stays short and scannable; the full text follows it.
|
|
103
|
+
if (reason) parts.push(`reason: ${reason.length > 200 ? `${reason.slice(0, 200)}…` : reason}`);
|
|
97
104
|
let out = parts.length ? `${label} (${parts.join('; ')})` : label;
|
|
105
|
+
|
|
106
|
+
// ORDER MATTERS: summary, then the REMEDY, then the verbatim block last. The
|
|
107
|
+
// remedy is the actionable sentence and must not end up stranded below twenty
|
|
108
|
+
// lines of git chatter where it reads as a footnote to the quote.
|
|
98
109
|
if (NON_FAST_FORWARD_RE.test(reason)) {
|
|
99
110
|
out += opts.localHistoryRewritten === true
|
|
100
111
|
? ` — your LOCAL history was rewritten (a rebase or amend), so ${branch} is no longer a `
|
|
@@ -104,6 +115,17 @@ function formatPublishFailure(data, status, branch, opts = {}) {
|
|
|
104
115
|
: ` — the server already moved ${branch}: fetch origin, reset onto origin/${branch}, `
|
|
105
116
|
+ 're-run the generators, and re-ship so the push fast-forwards.';
|
|
106
117
|
}
|
|
118
|
+
|
|
119
|
+
// The untruncated remote message, verbatim and indented so it reads as a quotation
|
|
120
|
+
// rather than as more ship output. Only when it is multi-line — a single-line
|
|
121
|
+
// stderr is already fully present in the summary above, and repeating it is noise.
|
|
122
|
+
const full = details.reason ? String(details.reason).trimEnd() : '';
|
|
123
|
+
if (full && /\n/.test(full)) {
|
|
124
|
+
out += `\n\n --- what the remote actually said ---\n${full.split('\n').map((l) => ` ${l}`).join('\n')}\n --- end ---`;
|
|
125
|
+
if (details.reason_bytes != null && details.reason_bytes > full.length) {
|
|
126
|
+
out += `\n (${details.reason_bytes} bytes at the server; the middle was omitted here)`;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
107
129
|
return out;
|
|
108
130
|
}
|
|
109
131
|
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// scripts/gds/ship-visual.js — the `--visual` leg of a ship (task 1003109, goal
|
|
4
|
+
// 1000072 "Human legibility").
|
|
5
|
+
//
|
|
6
|
+
// A shipped task can carry an optional IMAGE alongside its value summary: a
|
|
7
|
+
// screenshot of the surface that changed, a before/after, a diagram — so a reader
|
|
8
|
+
// who does not read code can SEE what shipped instead of reading about it (owner
|
|
9
|
+
// idea 1000400). ship.js owns the ship; this file owns the picture.
|
|
10
|
+
//
|
|
11
|
+
// Its own file, not another 60 lines inside the 4,600-line ship.js, because the
|
|
12
|
+
// size ratchet (fitness-ratchets.js, task 1003129) is shrink-only — and because
|
|
13
|
+
// the two things really are separable: the ship must not care whether a visual
|
|
14
|
+
// came along.
|
|
15
|
+
//
|
|
16
|
+
// THE ONE RULE HERE: a visual never costs a builder their ship. Bad input is
|
|
17
|
+
// caught BEFORE the claim resolves (a usage error, claim intact); a failed upload
|
|
18
|
+
// happens AFTER it and is a warning, never a failure.
|
|
19
|
+
|
|
20
|
+
const path = require('node:path');
|
|
21
|
+
const { arg } = require('./cli-lib');
|
|
22
|
+
// Reading + screening is the SERVER's own lib, so a terminal refuses exactly what
|
|
23
|
+
// the upload route would refuse.
|
|
24
|
+
const taskVisuals = require('../../modules/lifecycle/task-visuals');
|
|
25
|
+
|
|
26
|
+
// Parse + screen `--visual <file> [--visual-alt "caption"]` from an argv slice.
|
|
27
|
+
// Returns null when no visual was asked for, or { buf, contentType, alt, name }.
|
|
28
|
+
// EXITS (code 2) on bad input: this runs before any heavy work, so a wrong path
|
|
29
|
+
// costs the builder a retyped command, not a claim. Pure enough to reason about;
|
|
30
|
+
// the exit is the deliberate CLI edge.
|
|
31
|
+
function fromArgs(args) {
|
|
32
|
+
const file = arg('--visual', args) || '';
|
|
33
|
+
if (!file) return null;
|
|
34
|
+
const read = taskVisuals.readImageFile(file);
|
|
35
|
+
if (!read.ok) {
|
|
36
|
+
console.error(`Ship aborted: --visual ${file} — ${read.message}`);
|
|
37
|
+
console.error('Fix the path (or drop --visual: a visual is always optional). No claim resolved, no credits awarded.');
|
|
38
|
+
process.exit(2);
|
|
39
|
+
}
|
|
40
|
+
const visual = { ...read, alt: arg('--visual-alt', args) || '', name: path.basename(file) };
|
|
41
|
+
console.log(`--visual: ${visual.name} (${visual.contentType}, ${Math.round(visual.buf.length / 1024)} KB) — uploaded after the claim resolves.`);
|
|
42
|
+
return visual;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Upload the screened visual to the task. BEST-EFFORT BY DESIGN: called AFTER the
|
|
46
|
+
// claim has resolved, so a failure here must never fail the ship — the work
|
|
47
|
+
// landed, only the picture did not. Returns true when it stuck; prints a
|
|
48
|
+
// one-line, actionable warning and returns false when it did not.
|
|
49
|
+
async function upload({ taskId, visual, client, errorLine }) {
|
|
50
|
+
if (!visual) return false;
|
|
51
|
+
const r = await client.tasks.postTasksIdVisual({
|
|
52
|
+
id: taskId,
|
|
53
|
+
body: {
|
|
54
|
+
image_b64: visual.buf.toString('base64'),
|
|
55
|
+
content_type: visual.contentType,
|
|
56
|
+
...(visual.alt ? { alt: visual.alt } : {}),
|
|
57
|
+
},
|
|
58
|
+
});
|
|
59
|
+
if (r.ok) {
|
|
60
|
+
console.log("Visual attached — it renders wherever this task's value summary does.");
|
|
61
|
+
return true;
|
|
62
|
+
}
|
|
63
|
+
console.warn(`Note: the visual did not upload (${errorLine(r.data, r.status)}). The ship is unaffected — re-attach any time with:`);
|
|
64
|
+
console.warn(` node scripts/gds/task.js visual ${taskId} <image> [--alt "caption"]`);
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
module.exports = { fromArgs, upload };
|
package/scripts/gds/task.js
CHANGED
|
@@ -11,11 +11,16 @@
|
|
|
11
11
|
// [--priority 3] [--version <id>] [--goal <id>] [--criteria C1,C2]
|
|
12
12
|
// [--touches a/,b/] [--est-minutes N] [--credits N] [--ready] [--json]
|
|
13
13
|
// bongos task show <id> [--json]
|
|
14
|
+
// bongos task visual <id> <image> [--alt "caption"] attach/replace the ship-time visual
|
|
15
|
+
// bongos task visual <id> --remove take it back off
|
|
14
16
|
//
|
|
15
17
|
// Task CREATION is Archon-gated SERVER-SIDE (creating work shapes scope, ADR 0016). This
|
|
16
18
|
// CLI adds no authority — a lower rank gets a clear 403 message pointing at idea capture.
|
|
17
19
|
|
|
18
20
|
const { cliClient, requireSession, arg, hasFlag } = require('./cli-lib');
|
|
21
|
+
// task 1003109: the ship-time VISUAL. The CLI screens the file with the SERVER's
|
|
22
|
+
// own constants, so what it accepts is exactly what the upload route accepts.
|
|
23
|
+
const taskVisuals = require('../../modules/lifecycle/task-visuals');
|
|
19
24
|
|
|
20
25
|
// Valid enums (mirrors the server's task-classifier vocab; a friendly client-side check
|
|
21
26
|
// so an obvious typo fails before the round-trip — the server stays authoritative).
|
|
@@ -77,6 +82,8 @@ function usage() {
|
|
|
77
82
|
console.error(' [--priority 3] [--version <id>] [--goal <id>] [--criteria C1,C2]');
|
|
78
83
|
console.error(' [--touches a/,b/] [--est-minutes N] [--credits N] [--ready] [--json]');
|
|
79
84
|
console.error(' bongos task show <id> [--json]');
|
|
85
|
+
console.error(' bongos task visual <id> <image> [--alt "caption"] attach or replace the ship-time visual');
|
|
86
|
+
console.error(' bongos task visual <id> --remove remove it again');
|
|
80
87
|
console.error(`\n kinds: ${VALID_KINDS.join(' | ')}`);
|
|
81
88
|
console.error(` disciplines: ${VALID_DISCIPLINES.join(' | ')}`);
|
|
82
89
|
}
|
|
@@ -149,6 +156,60 @@ async function cmdShow(args) {
|
|
|
149
156
|
if (task.description) console.log(`\n${task.description}`);
|
|
150
157
|
}
|
|
151
158
|
|
|
159
|
+
|
|
160
|
+
// ---- bongos task visual (task 1003109, goal 1000072) ------------------------
|
|
161
|
+
//
|
|
162
|
+
// A shipped task can carry a VISUAL alongside its value summary — a screenshot of
|
|
163
|
+
// the surface that changed, a before/after, a diagram — so a reader who does not
|
|
164
|
+
// read code can SEE what changed. `ship.js --visual` is the usual path; this is
|
|
165
|
+
// the after-the-fact one: attach the picture you forgot, replace the wrong one,
|
|
166
|
+
// or take it back off. Same server gate either way — the task's claim holder (or
|
|
167
|
+
// an Archon). Always optional: no task needs one.
|
|
168
|
+
async function cmdVisual(args) {
|
|
169
|
+
const id = Number(args[0]);
|
|
170
|
+
if (!Number.isInteger(id) || id <= 0) {
|
|
171
|
+
console.error('usage: bongos task visual <id> <image> [--alt "caption"]');
|
|
172
|
+
console.error(' bongos task visual <id> --remove');
|
|
173
|
+
process.exit(2);
|
|
174
|
+
}
|
|
175
|
+
const api = await cliClient();
|
|
176
|
+
|
|
177
|
+
if (hasFlag('--remove', args)) {
|
|
178
|
+
const r = await api.tasks.deleteTasksIdVisual({ id });
|
|
179
|
+
if (r.status === 404) { console.error(`task #${id} not found.`); process.exit(1); }
|
|
180
|
+
if (!r.ok) { console.error(`visual remove failed (${r.status}):`, r.data); process.exit(1); }
|
|
181
|
+
console.log(`✓ Task #${id} no longer carries a visual.`);
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// The image is POSITIONAL — `visual <id> <image>`. Deliberately not "the first
|
|
186
|
+
// arg that isn't a flag": with `--alt "a caption" shot.png` that heuristic
|
|
187
|
+
// picks up the caption as the filename.
|
|
188
|
+
const file = args[1] && !args[1].startsWith('--') ? args[1] : null;
|
|
189
|
+
if (!file) {
|
|
190
|
+
console.error('usage: bongos task visual <id> <image> [--alt "caption"]');
|
|
191
|
+
process.exit(2);
|
|
192
|
+
}
|
|
193
|
+
// Reading + screening is the SERVER's own lib (shared with ship.js --visual),
|
|
194
|
+
// so a terminal refuses exactly what the upload route would refuse.
|
|
195
|
+
const visual = taskVisuals.readImageFile(file);
|
|
196
|
+
if (!visual.ok) {
|
|
197
|
+
console.error(`${file} — ${visual.message}`);
|
|
198
|
+
process.exit(2);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const alt = arg('--alt', args) || '';
|
|
202
|
+
const r = await api.tasks.postTasksIdVisual({
|
|
203
|
+
id,
|
|
204
|
+
body: { image_b64: visual.buf.toString('base64'), content_type: visual.contentType, ...(alt ? { alt } : {}) },
|
|
205
|
+
});
|
|
206
|
+
if (r.status === 404) { console.error(`task #${id} not found.`); process.exit(1); }
|
|
207
|
+
if (r.status === 403) { console.error(`Only task #${id}'s claim holder (or an Archon) may attach its visual.`); process.exit(1); }
|
|
208
|
+
if (!r.ok) { console.error(`visual upload failed (${r.status}):`, r.data); process.exit(1); }
|
|
209
|
+
console.log(`✓ Task #${id} now carries a visual (${Math.round(visual.buf.length / 1024)} KB).`);
|
|
210
|
+
console.log(' It renders wherever this task\'s value summary does.');
|
|
211
|
+
}
|
|
212
|
+
|
|
152
213
|
async function main() {
|
|
153
214
|
const args = process.argv.slice(2);
|
|
154
215
|
const sub = args[0];
|
|
@@ -156,6 +217,7 @@ async function main() {
|
|
|
156
217
|
await requireSession();
|
|
157
218
|
if (sub === 'new') return cmdNew(args.slice(1));
|
|
158
219
|
if (sub === 'show') return cmdShow(args.slice(1));
|
|
220
|
+
if (sub === 'visual') return cmdVisual(args.slice(1));
|
|
159
221
|
console.error(`task: unknown subcommand "${sub}"\n`); usage(); process.exit(2);
|
|
160
222
|
}
|
|
161
223
|
|
|
@@ -43,6 +43,14 @@ function projectPublicTask(task) {
|
|
|
43
43
|
payload.title = task.title;
|
|
44
44
|
payload.value_summary = task.value_summary ?? null;
|
|
45
45
|
payload.shipped_at = task.shipped_at ?? null;
|
|
46
|
+
// task 1003109: the ship-time VISUAL travels with the summary it restates —
|
|
47
|
+
// one claim, one audience, in both media. The extra `security_sensitive`
|
|
48
|
+
// narrowing is the serve route's own gate (task-visuals.isPubliclyViewable);
|
|
49
|
+
// publishing a URL this projection would then refuse to serve would be a
|
|
50
|
+
// broken image, so the two agree here.
|
|
51
|
+
const visualIsPublic = task.security_sensitive !== true;
|
|
52
|
+
payload.visual_url = visualIsPublic ? (task.visual_url ?? null) : null;
|
|
53
|
+
payload.visual_alt = visualIsPublic ? (task.visual_alt ?? null) : null;
|
|
46
54
|
}
|
|
47
55
|
return payload;
|
|
48
56
|
}
|
package/src/bongos/routes.js
CHANGED
|
@@ -86,7 +86,11 @@ function buildGdsRouter() {
|
|
|
86
86
|
req.path === '/memory/sync' ||
|
|
87
87
|
req.path === '/sessions' ||
|
|
88
88
|
req.path === '/llm-cache/store' ||
|
|
89
|
-
/^\/tasks\/\d+\/publish-branch$/.test(req.path)
|
|
89
|
+
/^\/tasks\/\d+\/publish-branch$/.test(req.path) ||
|
|
90
|
+
// task 1003109: the ship-time task visual — a base64 image up to ~5.3 MB
|
|
91
|
+
// encoded (4 MB decoded). Same dynamic-:id pattern as publish-branch above;
|
|
92
|
+
// modules/lifecycle/routes/task-visuals.js mounts its own 8 MB parser.
|
|
93
|
+
/^\/tasks\/\d+\/visual$/.test(req.path)
|
|
90
94
|
) return next();
|
|
91
95
|
return globalJson(req, res, next);
|
|
92
96
|
});
|