@bongos/core 1.19.646 → 1.19.648
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 +48 -33
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +4 -0
- package/clients/bongos-client/index.cjs +4 -0
- package/clients/bongos-client/index.d.ts +8 -1
- package/clients/bongos-client/index.mjs +4 -0
- package/docs/api/openapi.json +142 -3
- package/docs/api-reference.md +5 -3
- package/docs/module-api-changelog.md +4 -0
- package/migrations/core_237_box_widen_paths.sql +40 -0
- package/modules/dev-box/app/src/vendor/bongos-client.cjs +4 -0
- package/modules/dev-box/box-access.js +128 -4
- package/modules/dev-box/boxes.js +21 -0
- package/modules/dev-box/routes/box.js +108 -2
- package/modules/lifecycle/db-tasks.js +13 -1
- package/modules/lifecycle/db.js +40 -64
- package/modules/lifecycle/routes/task-write-routes.js +28 -2
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/box-sync.js +84 -1
- package/scripts/gds/ship-flow.js +69 -10
- package/src/module-api.js +1 -1
- package/tests/box_access.mjs +155 -0
- package/tests/box_sync_scope_report.mjs +45 -0
- package/tests/home_bounded_reads.mjs +14 -5
- package/tests/lifecycle_facade_surface.mjs +112 -0
- package/tests/migration_allocation.mjs +28 -0
- package/tests/patch_gate_ordering.mjs +4 -0
- package/tests/patch_value_summary_e2e.mjs +142 -0
- package/tests/ship_cannot_lie.mjs +42 -13
|
@@ -254,6 +254,117 @@ function sparsePathsForScopeKeys(scopeKeys, moduleScopeMap) {
|
|
|
254
254
|
return [...base, ...extra];
|
|
255
255
|
}
|
|
256
256
|
|
|
257
|
+
// ---------------------------------------------------------------------------
|
|
258
|
+
// PURE: builder-requested WIDENING (task 1003087 / idea 1000793)
|
|
259
|
+
// ---------------------------------------------------------------------------
|
|
260
|
+
|
|
261
|
+
// A builder sometimes needs a directory their task scope did not pull — to run
|
|
262
|
+
// the full suite, to read a caller, to regenerate a TREE-DEPENDENT artifact. The
|
|
263
|
+
// obvious move, `git sparse-checkout add <path>`, does not survive: the */10
|
|
264
|
+
// source-fetch cron re-applies the SERVER-computed set and deletes the addition,
|
|
265
|
+
// including WHILE A COMMAND IS RUNNING (a live suite died mid-run with
|
|
266
|
+
// MODULE_NOT_FOUND that read exactly like a code bug; a box-committed
|
|
267
|
+
// gen-repo-map regen off a narrow tree reds CI's freshness gate — learning
|
|
268
|
+
// 1000192, which stranded PR #254).
|
|
269
|
+
//
|
|
270
|
+
// So the widening is recorded SERVER-side and unioned in HERE, which is what
|
|
271
|
+
// makes it tick-surviving: the cron asks this endpoint for the set on every tick,
|
|
272
|
+
// so it gets the union too. Nothing in the instance-owned fetch script changes —
|
|
273
|
+
// which matters, because the neutral core does not ship it (ADR 0150).
|
|
274
|
+
|
|
275
|
+
// Bounds. Small on purpose: this is an escape hatch for a handful of dirs, not a
|
|
276
|
+
// second scope system. Over the cap the extras are dropped, not an error — a
|
|
277
|
+
// truncated widen still fetches, and failing the source pull over it would be a
|
|
278
|
+
// far worse trade than materializing less.
|
|
279
|
+
const MAX_WIDEN_PATHS = 24;
|
|
280
|
+
const MAX_WIDEN_PATH_LEN = 200;
|
|
281
|
+
|
|
282
|
+
// withinAny(p, roots) — is p one of roots, or underneath one? Cone mode
|
|
283
|
+
// materializes a directory and everything below it, so "already covered" and
|
|
284
|
+
// "inside the allowed surface" are the same ancestor walk.
|
|
285
|
+
function withinAny(p, roots) {
|
|
286
|
+
let q = p;
|
|
287
|
+
for (;;) {
|
|
288
|
+
if (roots.includes(q)) return true;
|
|
289
|
+
const cut = q.lastIndexOf('/');
|
|
290
|
+
if (cut < 0) return false;
|
|
291
|
+
q = q.slice(0, cut);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// normalizeWidenPath — repo-relative POSIX directory, or null when it is not one.
|
|
296
|
+
//
|
|
297
|
+
// This is a path that reaches `git sparse-checkout set` on a box, so it is
|
|
298
|
+
// validated as INPUT rather than trusted: absolute paths, Windows drive letters,
|
|
299
|
+
// '..' traversal and empty segments are all refused rather than normalized away,
|
|
300
|
+
// because "cleaned up into something that still works" is how a traversal becomes
|
|
301
|
+
// a shrug. Backslashes are folded to '/' first so a Windows-typed path is judged
|
|
302
|
+
// on what it means, not on its separator.
|
|
303
|
+
function normalizeWidenPath(raw) {
|
|
304
|
+
if (typeof raw !== 'string') return null;
|
|
305
|
+
// BACKSLASH by char code, not as a literal: this file is edited through
|
|
306
|
+
// tooling that has silently collapsed a doubled backslash before, and a
|
|
307
|
+
// separator fold that quietly stops folding is invisible until a Windows
|
|
308
|
+
// path slips through as one long segment.
|
|
309
|
+
const BACKSLASH = String.fromCharCode(92);
|
|
310
|
+
let p = raw.trim().split(BACKSLASH).join('/');
|
|
311
|
+
while (p.startsWith('./')) p = p.slice(2);
|
|
312
|
+
p = p.replace(/\/+/g, '/').replace(/\/+$/, '');
|
|
313
|
+
if (!p || p === '.' || p.length > MAX_WIDEN_PATH_LEN) return null;
|
|
314
|
+
if (p.startsWith('/') || /^[A-Za-z]:/.test(p)) return null;
|
|
315
|
+
const segs = p.split('/');
|
|
316
|
+
if (!segs.every((s) => s && s !== '.' && s !== '..' && /^[A-Za-z0-9._-]+$/.test(s))) return null;
|
|
317
|
+
return p;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// admissibleWidenPaths — the widen requests this builder's RANK actually permits,
|
|
321
|
+
// normalized, deduped, capped and sorted.
|
|
322
|
+
//
|
|
323
|
+
// THIS IS THE SECURITY BOUNDARY OF THE FEATURE, so it is stated plainly: widening
|
|
324
|
+
// changes what is MATERIALIZED on a box, and rank decides how much of the repo a
|
|
325
|
+
// box may hold at all (ADR 0031 section 9.2 — 'full' for Metic+, the 'starter'
|
|
326
|
+
// surface below that). Honoring an arbitrary widen would hand a Xenos the whole
|
|
327
|
+
// tree through a convenience flag, which is a rank escalation dressed as
|
|
328
|
+
// ergonomics. So:
|
|
329
|
+
// - a builder whose RANK scope is 'full' may widen anywhere (they are already
|
|
330
|
+
// entitled to the entire repo; the widen only decides what gets pulled);
|
|
331
|
+
// - anyone else may widen only INSIDE the starter surface. That is not a null
|
|
332
|
+
// grant: a task-scoped box is BASE union its modules, which is NARROWER than
|
|
333
|
+
// starter, so this is exactly the room to climb back to what their rank
|
|
334
|
+
// already allowed — and no further.
|
|
335
|
+
// The authoring wall is untouched either way. What a builder may CHANGE is still
|
|
336
|
+
// goal-scope-check against the module globs, never what happens to be on disk
|
|
337
|
+
// (ADR 0148).
|
|
338
|
+
function admissibleWidenPaths(widenPaths, rank) {
|
|
339
|
+
const unrestricted = boxScopeForRank(rank) === 'full';
|
|
340
|
+
const out = [];
|
|
341
|
+
const seen = new Set();
|
|
342
|
+
for (const raw of Array.isArray(widenPaths) ? widenPaths : []) {
|
|
343
|
+
const p = normalizeWidenPath(raw);
|
|
344
|
+
if (!p || seen.has(p)) continue;
|
|
345
|
+
if (!unrestricted && !withinAny(p, STARTER_SPARSE_PATHS)) continue;
|
|
346
|
+
seen.add(p);
|
|
347
|
+
out.push(p);
|
|
348
|
+
if (out.length >= MAX_WIDEN_PATHS) break;
|
|
349
|
+
}
|
|
350
|
+
return out.sort();
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// withWidenPaths — union admitted widen dirs into a computed sparse set, skipping
|
|
354
|
+
// any the set already covers (cone mode pulls a dir and everything under it, so
|
|
355
|
+
// adding 'scripts/gds' when 'scripts' is present is noise in the git command and
|
|
356
|
+
// noise in the box's scope readout). A null set is the 'full' clone — there is
|
|
357
|
+
// nothing to widen into, so it passes through untouched.
|
|
358
|
+
function withWidenPaths(sparsePaths, admitted) {
|
|
359
|
+
if (!Array.isArray(sparsePaths) || !admitted || admitted.length === 0) return sparsePaths;
|
|
360
|
+
const out = [...sparsePaths];
|
|
361
|
+
for (const dir of admitted) {
|
|
362
|
+
if (withinAny(dir, out)) continue;
|
|
363
|
+
out.push(dir);
|
|
364
|
+
}
|
|
365
|
+
return out;
|
|
366
|
+
}
|
|
367
|
+
|
|
257
368
|
// ---------------------------------------------------------------------------
|
|
258
369
|
// PURE: the per-fetch access decision (consumed by GET /box/source-access)
|
|
259
370
|
// ---------------------------------------------------------------------------
|
|
@@ -295,7 +406,7 @@ function sparsePathsForScopeKeys(scopeKeys, moduleScopeMap) {
|
|
|
295
406
|
// { allowed:false, reason:'BUILDER_INACTIVE' }
|
|
296
407
|
// { allowed:false, reason:'INSUFFICIENT_RANK', required, actual }
|
|
297
408
|
// { allowed:true, scope, spec }
|
|
298
|
-
function decideSourceAccess({ rank, status, floor = DEFAULT_PROVISION_FLOOR, scopeKeys, hasActiveClaims, moduleScopeMap } = {}) {
|
|
409
|
+
function decideSourceAccess({ rank, status, floor = DEFAULT_PROVISION_FLOOR, scopeKeys, hasActiveClaims, moduleScopeMap, widenPaths } = {}) {
|
|
299
410
|
if (status && status !== 'active') {
|
|
300
411
|
return { allowed: false, reason: 'BUILDER_INACTIVE' };
|
|
301
412
|
}
|
|
@@ -307,23 +418,32 @@ function decideSourceAccess({ rank, status, floor = DEFAULT_PROVISION_FLOOR, sco
|
|
|
307
418
|
actual: rank ?? null,
|
|
308
419
|
};
|
|
309
420
|
}
|
|
421
|
+
// The builder's own widening, narrowed to what their rank already reaches
|
|
422
|
+
// (task 1003087). Computed once here so every sparse branch below unions the
|
|
423
|
+
// SAME set — a widen that applied to one scope and not another would be a
|
|
424
|
+
// surprise that only surfaces when a claim changes.
|
|
425
|
+
const widen = admissibleWidenPaths(widenPaths, rank);
|
|
426
|
+
|
|
310
427
|
// Claim-driven task scope (present → the box asks for exactly its work's code).
|
|
311
428
|
if (Array.isArray(scopeKeys)) {
|
|
312
429
|
if (scopeKeys.length > 0) {
|
|
313
|
-
const sparsePaths = sparsePathsForScopeKeys(scopeKeys, moduleScopeMap);
|
|
430
|
+
const sparsePaths = withWidenPaths(sparsePathsForScopeKeys(scopeKeys, moduleScopeMap), widen);
|
|
314
431
|
return { allowed: true, scope: 'task', spec: { scope: 'task', mode: 'sparse', sparsePaths } };
|
|
315
432
|
}
|
|
316
433
|
// Empty. A builder mid-claim whose goals declare no wall gets the rank scope,
|
|
317
434
|
// NOT a stripped box — the planning gap is not theirs to pay for.
|
|
318
435
|
if (!hasActiveClaims) {
|
|
319
|
-
const sparsePaths = sparsePathsForScopeKeys([], moduleScopeMap);
|
|
436
|
+
const sparsePaths = withWidenPaths(sparsePathsForScopeKeys([], moduleScopeMap), widen);
|
|
320
437
|
return { allowed: true, scope: 'base', spec: { scope: 'base', mode: 'sparse', sparsePaths } };
|
|
321
438
|
}
|
|
322
439
|
}
|
|
323
440
|
// No claim signal (no lifecycle port on this instance), or claims with an
|
|
324
441
|
// undeclared goal wall: the pre-ADR-0148 rank fallback.
|
|
325
442
|
const scope = boxScopeForRank(rank);
|
|
326
|
-
|
|
443
|
+
const spec = cloneSpecForScope(scope);
|
|
444
|
+
// 'full' carries sparsePaths null (the whole repo) — withWidenPaths passes it
|
|
445
|
+
// through, because there is no sparse set to widen INTO.
|
|
446
|
+
return { allowed: true, scope, spec: { ...spec, sparsePaths: withWidenPaths(spec.sparsePaths, widen) } };
|
|
327
447
|
}
|
|
328
448
|
|
|
329
449
|
// scopeMetadata — PURE. The credential-FREE projection of a source-access
|
|
@@ -470,6 +590,10 @@ module.exports = {
|
|
|
470
590
|
boxCanHoldSource,
|
|
471
591
|
cloneSpecForScope,
|
|
472
592
|
sparsePathsForScopeKeys, // ADR 0148 — BASE ∪ the claims' module dirs (cone-mode)
|
|
593
|
+
MAX_WIDEN_PATHS, // task 1003087 — the builder-widening cap
|
|
594
|
+
normalizeWidenPath, // task 1003087 — repo-relative dir, or null
|
|
595
|
+
admissibleWidenPaths, // task 1003087 — the widen requests this RANK permits
|
|
596
|
+
withWidenPaths, // task 1003087 — union a widen set into a sparse set
|
|
473
597
|
scopeMetadata, // BV1.R100 — credential-free projection for GET /box/scope
|
|
474
598
|
// pure — decisions
|
|
475
599
|
decideSourceAccess,
|
package/modules/dev-box/boxes.js
CHANGED
|
@@ -397,6 +397,26 @@ function decideEnsureAction({ boxState, rank, status, blocked = false, floor = D
|
|
|
397
397
|
// DB helpers (async). Each takes the pg pool (or a client mid-transaction).
|
|
398
398
|
// ---------------------------------------------------------------------------
|
|
399
399
|
|
|
400
|
+
// setBoxWidenPaths — replace a box row's builder-requested widen set (task
|
|
401
|
+
// 1003087). Whole-set replace rather than add/remove SQL: the set is tiny and
|
|
402
|
+
// capped, the caller has already normalized and rank-filtered it, and a
|
|
403
|
+
// read-modify-write in one statement keeps two concurrent widens from
|
|
404
|
+
// interleaving into a half-applied list.
|
|
405
|
+
//
|
|
406
|
+
// Returns the stored array, or null when the builder has no box row — the
|
|
407
|
+
// caller turns that into a 404 rather than silently creating one, because a
|
|
408
|
+
// widen with no box to apply it to is a request that has not happened yet.
|
|
409
|
+
async function setBoxWidenPaths(db, builderId, paths) {
|
|
410
|
+
const { rows } = await db.query(
|
|
411
|
+
`UPDATE builder_boxes
|
|
412
|
+
SET widen_paths = $2::text[], updated_at = now()
|
|
413
|
+
WHERE builder_id = $1
|
|
414
|
+
RETURNING widen_paths`,
|
|
415
|
+
[builderId, Array.isArray(paths) ? paths : []]
|
|
416
|
+
);
|
|
417
|
+
return rows[0] ? rows[0].widen_paths : null;
|
|
418
|
+
}
|
|
419
|
+
|
|
400
420
|
async function getBoxByBuilderId(db, builderId) {
|
|
401
421
|
const { rows } = await db.query(
|
|
402
422
|
`SELECT * FROM builder_boxes WHERE builder_id = $1`,
|
|
@@ -891,6 +911,7 @@ module.exports = {
|
|
|
891
911
|
decideEnsureAction,
|
|
892
912
|
// db
|
|
893
913
|
getBoxByBuilderId,
|
|
914
|
+
setBoxWidenPaths,
|
|
894
915
|
ensureBoxRow,
|
|
895
916
|
listBoxes,
|
|
896
917
|
recordEvent,
|
|
@@ -480,8 +480,16 @@ module.exports = function buildBoxRouter() {
|
|
|
480
480
|
hasActiveClaims = undefined;
|
|
481
481
|
}
|
|
482
482
|
}
|
|
483
|
-
|
|
483
|
+
// Read the box BEFORE deciding: its widen_paths are an INPUT to the sparse
|
|
484
|
+
// set now (task 1003087). A builder-requested directory is unioned in here,
|
|
485
|
+
// which is what makes it survive the */10 cron — the cron re-asks THIS
|
|
486
|
+
// endpoint every tick, so the union comes back with it instead of being
|
|
487
|
+
// deleted by the next `git sparse-checkout set`.
|
|
484
488
|
const box = await boxes.getBoxByBuilderId(pool, req.builder.id);
|
|
489
|
+
const decision = boxAccess.decideSourceAccess({
|
|
490
|
+
rank, status, scopeKeys, hasActiveClaims, moduleScopeMap: api.moduleScopeMap,
|
|
491
|
+
widenPaths: box ? box.widen_paths : null,
|
|
492
|
+
});
|
|
485
493
|
const auditDeny = (reason, extra) =>
|
|
486
494
|
box && boxAccess.recordSourceEvent(pool, {
|
|
487
495
|
box, builderId: req.builder.id, event: 'source_deny',
|
|
@@ -598,7 +606,13 @@ module.exports = function buildBoxRouter() {
|
|
|
598
606
|
hasActiveClaims = undefined;
|
|
599
607
|
}
|
|
600
608
|
}
|
|
601
|
-
|
|
609
|
+
// Same widen input as the real fetch — this readout exists precisely so it
|
|
610
|
+
// cannot drift from what the box will actually hold.
|
|
611
|
+
const scopeBox = await boxes.getBoxByBuilderId(pool, req.builder.id);
|
|
612
|
+
const decision = boxAccess.decideSourceAccess({
|
|
613
|
+
rank, status, scopeKeys, hasActiveClaims, moduleScopeMap: api.moduleScopeMap,
|
|
614
|
+
widenPaths: scopeBox ? scopeBox.widen_paths : null,
|
|
615
|
+
});
|
|
602
616
|
if (!decision.allowed) {
|
|
603
617
|
return res.fail('SOURCE_ACCESS_REVOKED', 403, {
|
|
604
618
|
reason: decision.reason,
|
|
@@ -753,6 +767,98 @@ module.exports = function buildBoxRouter() {
|
|
|
753
767
|
}
|
|
754
768
|
});
|
|
755
769
|
|
|
770
|
+
// GET /box/widen — the caller's own recorded widen set (task 1003087).
|
|
771
|
+
// rank: any authenticated builder, own resource. Reports what is STORED plus
|
|
772
|
+
// what is currently ADMITTED, because those differ whenever a request sits
|
|
773
|
+
// outside the builder's rank scope — showing only the stored list would let a
|
|
774
|
+
// Xenos believe a widen took effect that the fetch quietly drops.
|
|
775
|
+
router.get('/box/widen', auth.requireBuilder, async (req, res) => {
|
|
776
|
+
try {
|
|
777
|
+
const builder = await gdsDb.getBuilderById(req.builder.id);
|
|
778
|
+
const box = await boxes.getBoxByBuilderId(pool, req.builder.id);
|
|
779
|
+
if (!box) return res.fail('no_box', 404, { message: 'no dev box for this builder' });
|
|
780
|
+
const stored = Array.isArray(box.widen_paths) ? box.widen_paths : [];
|
|
781
|
+
const admitted = boxAccess.admissibleWidenPaths(stored, builder ? builder.rank : null);
|
|
782
|
+
res.json({
|
|
783
|
+
widen_paths: stored,
|
|
784
|
+
admitted,
|
|
785
|
+
ignored: stored.filter((p) => !admitted.includes(boxAccess.normalizeWidenPath(p))),
|
|
786
|
+
max: boxAccess.MAX_WIDEN_PATHS,
|
|
787
|
+
});
|
|
788
|
+
} catch (err) {
|
|
789
|
+
log.error('[gds] GET /box/widen', err);
|
|
790
|
+
res.fail('widen_read_failed', { status: 500, message: 'internal error' });
|
|
791
|
+
}
|
|
792
|
+
});
|
|
793
|
+
|
|
794
|
+
// POST /box/widen — record extra sparse-checkout directories that must SURVIVE
|
|
795
|
+
// the */10 source-fetch (task 1003087, idea 1000793). rank: any authenticated
|
|
796
|
+
// builder, OWN box (builder_id-scoped in SQL, so no id from the body can point
|
|
797
|
+
// this at someone else's box).
|
|
798
|
+
//
|
|
799
|
+
// Deliberately NOT allowBoxScope. The on-box crons read source-access with a
|
|
800
|
+
// box-scoped session (ADR 0053); this is a WRITE that changes what the box
|
|
801
|
+
// pulls forever after, so it takes the builder's own session — the same posture
|
|
802
|
+
// that keeps a stolen box token from re-scoping the checkout it was stolen from.
|
|
803
|
+
//
|
|
804
|
+
// add/remove are applied to the stored set and the result is re-normalized, so
|
|
805
|
+
// the endpoint is idempotent: widening twice is not an error, and neither is
|
|
806
|
+
// removing something that was never there.
|
|
807
|
+
//
|
|
808
|
+
// A path outside the caller's rank scope is ACCEPTED into storage but reported
|
|
809
|
+
// in `ignored` rather than refused. That is the honest shape: rank can change,
|
|
810
|
+
// and a builder promoted to Metic should find the widen they asked for as a
|
|
811
|
+
// Xenos simply start working, instead of having been silently discarded months
|
|
812
|
+
// earlier. What it can never do is take effect early — admissibleWidenPaths is
|
|
813
|
+
// re-evaluated against the LIVE rank on every single fetch.
|
|
814
|
+
router.post('/box/widen', auth.requireBuilder, async (req, res) => {
|
|
815
|
+
if (validateOrRespond(req, res, {
|
|
816
|
+
add: { type: 'array' },
|
|
817
|
+
remove: { type: 'array' },
|
|
818
|
+
clear: { type: 'boolean' },
|
|
819
|
+
})) return;
|
|
820
|
+
try {
|
|
821
|
+
const box = await boxes.getBoxByBuilderId(pool, req.builder.id);
|
|
822
|
+
if (!box) return res.fail('no_box', 404, { message: 'no dev box for this builder' });
|
|
823
|
+
const builder = await gdsDb.getBuilderById(req.builder.id);
|
|
824
|
+
|
|
825
|
+
const current = Array.isArray(box.widen_paths) ? box.widen_paths : [];
|
|
826
|
+
const next = new Set(req.body && req.body.clear === true ? [] : current);
|
|
827
|
+
|
|
828
|
+
const rejected = [];
|
|
829
|
+
for (const raw of Array.isArray(req.body && req.body.add) ? req.body.add : []) {
|
|
830
|
+
const p = boxAccess.normalizeWidenPath(raw);
|
|
831
|
+
if (!p) { rejected.push(String(raw).slice(0, 120)); continue; }
|
|
832
|
+
next.add(p);
|
|
833
|
+
}
|
|
834
|
+
for (const raw of Array.isArray(req.body && req.body.remove) ? req.body.remove : []) {
|
|
835
|
+
const p = boxAccess.normalizeWidenPath(raw);
|
|
836
|
+
if (p) next.delete(p);
|
|
837
|
+
}
|
|
838
|
+
if (rejected.length) {
|
|
839
|
+
return res.fail('bad_widen_path', 400, {
|
|
840
|
+
rejected,
|
|
841
|
+
message: 'a widen path must be a repo-relative directory: no absolute paths, no "..", no drive letters',
|
|
842
|
+
});
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
const stored = [...next].sort().slice(0, boxAccess.MAX_WIDEN_PATHS);
|
|
846
|
+
const saved = await boxes.setBoxWidenPaths(pool, req.builder.id, stored);
|
|
847
|
+
if (saved === null) return res.fail('no_box', 404, { message: 'no dev box for this builder' });
|
|
848
|
+
const admitted = boxAccess.admissibleWidenPaths(saved, builder ? builder.rank : null);
|
|
849
|
+
res.json({
|
|
850
|
+
ok: true,
|
|
851
|
+
widen_paths: saved,
|
|
852
|
+
admitted,
|
|
853
|
+
ignored: saved.filter((p) => !admitted.includes(p)),
|
|
854
|
+
max: boxAccess.MAX_WIDEN_PATHS,
|
|
855
|
+
});
|
|
856
|
+
} catch (err) {
|
|
857
|
+
log.error('[gds] POST /box/widen', err);
|
|
858
|
+
res.fail('widen_failed', { status: 500, message: 'internal error' });
|
|
859
|
+
}
|
|
860
|
+
});
|
|
861
|
+
|
|
756
862
|
// GET /box/authorized-keys — the box pulls its builder's authorized_keys.
|
|
757
863
|
// rank: any authenticated builder (own resource) — but the ALLOW/DENY is decided
|
|
758
864
|
// INSIDE from the caller's LIVE rank+status (boxAccess.decideSourceAccess), NOT a
|
|
@@ -308,7 +308,7 @@ async function findTaskBySourceRef({ source, sourceRef } = {}, deps = {}) {
|
|
|
308
308
|
|
|
309
309
|
const TASK_SETTER_COLUMNS = new Set([
|
|
310
310
|
'kind', 'discipline', 'newcomer_friendly', 'security_sensitive', 'parallel_safe', 'automation_tag',
|
|
311
|
-
'needs_migration', 'module_key', 'description', 'title', 'priority',
|
|
311
|
+
'needs_migration', 'module_key', 'description', 'title', 'priority', 'value_summary',
|
|
312
312
|
]);
|
|
313
313
|
|
|
314
314
|
async function updateTaskColumn(id, column, value) {
|
|
@@ -383,6 +383,17 @@ async function updateTaskTitle(id, title) { return updateTaskColumn(id, 'title',
|
|
|
383
383
|
// 1 = most urgent, 5 = least; null clears it back to unset (weighted as 5).
|
|
384
384
|
async function updateTaskPriority(id, priority) { return updateTaskColumn(id, 'priority', priority); }
|
|
385
385
|
|
|
386
|
+
// task 1003817: the shipped-value sentence — what the hall, /status and the
|
|
387
|
+
// Discord #ship-news line all read. It was written in exactly ONE place, the
|
|
388
|
+
// claim-resolve route, so a re-grade (which has no claim — regradeMain
|
|
389
|
+
// synthesizes a claimLike precisely because the claim is gone) could correct
|
|
390
|
+
// the summary in the PR and the merge commit and still leave the RECORD holding
|
|
391
|
+
// the first ship's sentence forever. This is the write vector that path uses.
|
|
392
|
+
// Rewritable, never blankable: the route refuses an empty or whitespace-only
|
|
393
|
+
// value, which is this column's form of the COALESCE/NULLIF posture db-ship.js
|
|
394
|
+
// keeps on every other value_summary write.
|
|
395
|
+
async function updateTaskValueSummary(id, valueSummary) { return updateTaskColumn(id, 'value_summary', valueSummary); }
|
|
396
|
+
|
|
386
397
|
|
|
387
398
|
async function updateTaskDiscipline(id, discipline) { return updateTaskColumn(id, 'discipline', discipline); }
|
|
388
399
|
|
|
@@ -1037,5 +1048,6 @@ module.exports = {
|
|
|
1037
1048
|
updateTaskRequiresRank,
|
|
1038
1049
|
updateTaskSecuritySensitive,
|
|
1039
1050
|
updateTaskStatus,
|
|
1051
|
+
updateTaskValueSummary,
|
|
1040
1052
|
versionAcceptsNewWork,
|
|
1041
1053
|
};
|
package/modules/lifecycle/db.js
CHANGED
|
@@ -30,10 +30,16 @@
|
|
|
30
30
|
// FACADE (task 1003204, goal 1000079). This file was a 5,748-line monolith; the
|
|
31
31
|
// R03 de-monolith plan carved it into the twelve db-*.js units required below,
|
|
32
32
|
// each cohesive and well under the 1,500-line ratchet. The carve was STRUCTURAL:
|
|
33
|
-
// no function body changed, and
|
|
34
|
-
// original one verbatim — so every `require('./db')` call site, and every name
|
|
33
|
+
// no function body changed, and every `require('./db')` call site, and every name
|
|
35
34
|
// reachable through it, behaves exactly as before.
|
|
36
35
|
//
|
|
36
|
+
// The module.exports block was the carve's ORIGINAL list verbatim until task
|
|
37
|
+
// 1003817, which replaced the db-tasks.js third of it with a spread — see the
|
|
38
|
+
// long note above `const dbTasks` for why, and tests/lifecycle_facade_surface.mjs
|
|
39
|
+
// for the assertion that keeps that surface identical. The other eleven units
|
|
40
|
+
// are still re-listed by name; giving one of them a spread means giving it an
|
|
41
|
+
// omit list and a surface pin too, because a bare spread publishes internals.
|
|
42
|
+
//
|
|
37
43
|
// The units form a DAG (no require cycles), in dependency order:
|
|
38
44
|
// db-shared → db-rank-authz → {db-versions, db-goals, db-tasks} → db-analytics
|
|
39
45
|
// → db-ship → db-claims; db-deps-criteria and db-claim-reads stand alone.
|
|
@@ -100,38 +106,37 @@ const {
|
|
|
100
106
|
setGoalVisibility,
|
|
101
107
|
transferGoalOwnership,
|
|
102
108
|
} = require('./db-goals.js');
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
} = require('./db-tasks.js');
|
|
109
|
+
// task 1003817: db-tasks.js's exports ARE this facade's task surface, so they are
|
|
110
|
+
// spread into module.exports below instead of being re-listed name by name.
|
|
111
|
+
//
|
|
112
|
+
// WHY THE TWIN LIST HAD TO GO. What stood here was 31 sorted names destructured
|
|
113
|
+
// from db-tasks.js purely so module.exports could name them again — not one was
|
|
114
|
+
// called anywhere inside this file. Two hand-maintained copies of one list is
|
|
115
|
+
// real duplication, and the `duplicate_window_count` ratchet reads it as such:
|
|
116
|
+
// the lists were 29 identical normalized lines, one under the 30-line window, so
|
|
117
|
+
// task 1003750 adding `updateTaskPriority` to both pushed the run to 30 and cost
|
|
118
|
+
// an owner-decided baseline raise (59 → 60). That decision is recorded in
|
|
119
|
+
// config/fitness-baselines.json along with the instruction for whoever added the
|
|
120
|
+
// NEXT db-tasks function — this task, adding updateTaskValueSummary: fix the
|
|
121
|
+
// facade, do not raise the baseline again. Spreading removes the whole class, so
|
|
122
|
+
// the next setter costs nothing.
|
|
123
|
+
//
|
|
124
|
+
// TASK_FACADE_OMIT is what keeps this surface-PRESERVING rather than quietly
|
|
125
|
+
// wider. Two of db-tasks.js's 33 exports were deliberately never re-exported
|
|
126
|
+
// here, and a bare spread would publish them — which ADR 0091 §3 treats as a
|
|
127
|
+
// real surface change, not a tidy-up. Naming them means dropping one has to be
|
|
128
|
+
// somebody's decision. tests/lifecycle_facade_surface.mjs pins the resulting key
|
|
129
|
+
// set, so this can never silently widen or narrow.
|
|
130
|
+
const dbTasks = require('./db-tasks.js');
|
|
131
|
+
const TASK_FACADE_OMIT = new Set([
|
|
132
|
+
// Internal to db-tasks.js's own queries — a SQL fragment, not a callable.
|
|
133
|
+
'CREDITS_AWARDED_SUBQUERY',
|
|
134
|
+
// The version-gate predicate; routes reach it through db-versions, not here.
|
|
135
|
+
'versionAcceptsNewWork',
|
|
136
|
+
]);
|
|
137
|
+
const dbTasksFacade = Object.fromEntries(
|
|
138
|
+
Object.entries(dbTasks).filter(([name]) => !TASK_FACADE_OMIT.has(name))
|
|
139
|
+
);
|
|
135
140
|
const {
|
|
136
141
|
addDependency,
|
|
137
142
|
addTaskCriterion,
|
|
@@ -214,6 +219,7 @@ const {
|
|
|
214
219
|
} = require('./db-claims.js');
|
|
215
220
|
|
|
216
221
|
module.exports = {
|
|
222
|
+
...dbTasksFacade,
|
|
217
223
|
tallyPeerVotes,
|
|
218
224
|
PEER_VOTE_KARMA_LAG_DAYS,
|
|
219
225
|
RANKS,
|
|
@@ -263,19 +269,6 @@ module.exports = {
|
|
|
263
269
|
listCriteriaForGoal,
|
|
264
270
|
archiveGoalWithDispositions,
|
|
265
271
|
openTasksInGoal,
|
|
266
|
-
createTask,
|
|
267
|
-
generalGoalIdForVersion,
|
|
268
|
-
findTaskBySourceRef,
|
|
269
|
-
getTask,
|
|
270
|
-
listTasks,
|
|
271
|
-
updateTaskGoal,
|
|
272
|
-
createCriterion,
|
|
273
|
-
updateTaskKind,
|
|
274
|
-
updateTaskDescription,
|
|
275
|
-
updateTaskTitle,
|
|
276
|
-
updateTaskDiscipline,
|
|
277
|
-
updateTaskNewcomerFriendly,
|
|
278
|
-
updateTaskRequiresRank,
|
|
279
272
|
xenosClaimAllowed,
|
|
280
273
|
rankAllowsTask,
|
|
281
274
|
REQUIRES_RANK_TIER,
|
|
@@ -290,31 +283,14 @@ module.exports = {
|
|
|
290
283
|
LIVE_RANK_LADDER,
|
|
291
284
|
deriveRequiredRank,
|
|
292
285
|
highestRank,
|
|
293
|
-
rewardMissing,
|
|
294
|
-
suggestCreditsReward,
|
|
295
|
-
REWARD_SUGGESTION_CAP,
|
|
296
|
-
rewardGateAssignment,
|
|
297
|
-
rewardGateError,
|
|
298
|
-
setCreditsReward,
|
|
299
286
|
THETES_GRADUATION_THRESHOLD,
|
|
300
287
|
qualifiesForThetesGraduation,
|
|
301
288
|
countShippedTasksForBuilder,
|
|
302
289
|
singleRungPromotionError,
|
|
303
290
|
disambiguateWorktreeName,
|
|
304
291
|
foldWorktreeName,
|
|
305
|
-
updateTaskParallelSafe,
|
|
306
|
-
updateTaskPriority,
|
|
307
|
-
updateTaskSecuritySensitive,
|
|
308
|
-
updateTaskAutomationTag,
|
|
309
|
-
updateTaskNeedsMigration,
|
|
310
|
-
updateTaskModuleKey,
|
|
311
|
-
listClaimableTasks,
|
|
312
292
|
capNewcomerTasks,
|
|
313
|
-
taskTouchesProtectedPath, // SR-12 / G2 (task 994): autonomous-fit protected-path exclusion (pure, testable)
|
|
314
293
|
decorateGoalMembership, // task 1003137: feed-side joinable flag (pure, testable)
|
|
315
|
-
rankCrossDisciplineRecommendations,
|
|
316
|
-
listActiveClaims,
|
|
317
|
-
updateTaskStatus,
|
|
318
294
|
getTaskDependencies,
|
|
319
295
|
getTaskDependents,
|
|
320
296
|
attachTaskDepSummaries,
|
|
@@ -326,6 +326,14 @@ module.exports = function registerTaskWriteRoutes(router) {
|
|
|
326
326
|
// A whole-goal re-rank (needed-vs-nice) had no route and had to run as SQL.
|
|
327
327
|
// null clears it back to unset; the 1..5 bounds mirror tasks_priority_check.
|
|
328
328
|
priority: {}, // 1..5 or null — checked inline
|
|
329
|
+
// task 1003817: the shipped-value sentence. Written in exactly one place
|
|
330
|
+
// before this — the claim-resolve route — so `ship.js --regrade --summary`
|
|
331
|
+
// (no claim, by definition) could correct it in the PR and the merge commit
|
|
332
|
+
// and still leave the hall, /status and the Discord ship line reading the
|
|
333
|
+
// FIRST ship's text forever. Same task.edit permission and audit_log trail
|
|
334
|
+
// as the title/description/priority edits above; minLength 1 (plus the
|
|
335
|
+
// whitespace check below) so the record can be rewritten, never blanked.
|
|
336
|
+
value_summary: { type: 'string', minLength: 1, maxLength: LIMITS.VALUE_SUMMARY },
|
|
329
337
|
// task 1002614 (map D-18): DECLARED so strict-mode admits the request far
|
|
330
338
|
// enough to reach the deliberate status_not_patchable hint below (#514) —
|
|
331
339
|
// the hint had been unreachable dead code behind unknown_field since the
|
|
@@ -354,6 +362,7 @@ module.exports = function registerTaskWriteRoutes(router) {
|
|
|
354
362
|
const hasDescription = Object.prototype.hasOwnProperty.call(body, 'description');
|
|
355
363
|
const hasTitle = Object.prototype.hasOwnProperty.call(body, 'title');
|
|
356
364
|
const hasPriority = Object.prototype.hasOwnProperty.call(body, 'priority');
|
|
365
|
+
const hasValueSummary = Object.prototype.hasOwnProperty.call(body, 'value_summary');
|
|
357
366
|
// #514: status is a lifecycle field, not a PATCH-able column — it moves only
|
|
358
367
|
// through the dedicated transition routes. The #506 session tried
|
|
359
368
|
// `PATCH status=ready`, got the generic no_supported_fields error, and had
|
|
@@ -365,8 +374,8 @@ module.exports = function registerTaskWriteRoutes(router) {
|
|
|
365
374
|
if (Object.prototype.hasOwnProperty.call(body, 'status')) {
|
|
366
375
|
return res.fail('status_not_patchable', { status: 400, message: STATUS_HINT });
|
|
367
376
|
}
|
|
368
|
-
if (!hasParent && !hasKind && !hasDiscipline && !hasNewcomerOrXenos && !hasParallelSafe && !hasSecuritySensitive && !hasAutomationTag && !hasNeedsMigration && !hasRequiresRank && !hasModuleKey && !hasGoalId && !hasDescription && !hasTitle && !hasPriority) {
|
|
369
|
-
return res.fail('no_supported_fields', { status: 400, message: 'PATCH /tasks/:id supports parent_task_id, kind, discipline, title, description, priority, parallel_safe, security_sensitive, automation_tag, needs_migration, requires_rank, module_key, goal_id, and newcomer_friendly (alias: xenos_claimable). ' + STATUS_HINT });
|
|
377
|
+
if (!hasParent && !hasKind && !hasDiscipline && !hasNewcomerOrXenos && !hasParallelSafe && !hasSecuritySensitive && !hasAutomationTag && !hasNeedsMigration && !hasRequiresRank && !hasModuleKey && !hasGoalId && !hasDescription && !hasTitle && !hasPriority && !hasValueSummary) {
|
|
378
|
+
return res.fail('no_supported_fields', { status: 400, message: 'PATCH /tasks/:id supports parent_task_id, kind, discipline, title, description, value_summary, priority, parallel_safe, security_sensitive, automation_tag, needs_migration, requires_rank, module_key, goal_id, and newcomer_friendly (alias: xenos_claimable). ' + STATUS_HINT });
|
|
370
379
|
}
|
|
371
380
|
// ---- task 1002609 (G-22): settle EVERY gate and validation before the ----
|
|
372
381
|
// ---- FIRST write. The previous shape validated-and-wrote per field in ----
|
|
@@ -393,6 +402,14 @@ module.exports = function registerTaskWriteRoutes(router) {
|
|
|
393
402
|
return res.fail('bad_priority', { status: 400, message: `priority must be an integer ${TASK_FIELD_BOUNDS.priority.min}-${TASK_FIELD_BOUNDS.priority.max} (1 = most urgent), or null to clear` });
|
|
394
403
|
}
|
|
395
404
|
}
|
|
405
|
+
// task 1003817: minLength 1 refuses "" but not " ", and the write trims —
|
|
406
|
+
// so without this a whitespace-only body would blank the shipped-value
|
|
407
|
+
// record through the one field whose whole point is that it survives. An
|
|
408
|
+
// omitted field writes nothing at all (hasValueSummary is false), which is
|
|
409
|
+
// this route's form of the COALESCE/NULLIF posture db-ship.js keeps.
|
|
410
|
+
if (hasValueSummary && body.value_summary.trim() === '') {
|
|
411
|
+
return res.fail('bad_value_summary', { status: 400, message: 'value_summary must be a non-empty sentence — the shipped-value record can be rewritten, never blanked. Omit the field to leave it unchanged.' });
|
|
412
|
+
}
|
|
396
413
|
if (hasSecuritySensitive) {
|
|
397
414
|
// task 990: explicit Archon-only-broadcast flag. Boolean only (no null) —
|
|
398
415
|
// it gates whether ship.js routes the broadcast to the Archon-only
|
|
@@ -556,6 +573,15 @@ module.exports = function registerTaskWriteRoutes(router) {
|
|
|
556
573
|
return res.fail('patch_failed', { status: 500, message: 'internal error' });
|
|
557
574
|
}
|
|
558
575
|
}
|
|
576
|
+
if (hasValueSummary) {
|
|
577
|
+
try {
|
|
578
|
+
updated = await db.updateTaskValueSummary(id, body.value_summary.trim());
|
|
579
|
+
if (!updated) return res.fail('task_not_found', 404);
|
|
580
|
+
} catch (err) {
|
|
581
|
+
log.error('[gds] PATCH /tasks/:id value_summary', err);
|
|
582
|
+
return res.fail('patch_failed', { status: 500, message: 'internal error' });
|
|
583
|
+
}
|
|
584
|
+
}
|
|
559
585
|
if (hasGoalId) {
|
|
560
586
|
try {
|
|
561
587
|
updated = await db.updateTaskGoal(id, body.goal_id);
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.648",
|
|
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.648",
|
|
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.648",
|
|
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",
|