@bongos/core 1.21.125 → 1.21.126
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 +70 -40
- package/docs/api/openapi.json +4 -4
- package/docs/copy-inventory.md +128 -126
- package/docs/copy-registry.json +188 -170
- package/docs/module-api-changelog.md +3 -1
- package/docs/page-inventory.json +2 -1
- package/docs/page-readings.json +152 -143
- package/modules/hall-ui/public/goal-forest-lib.js +36 -0
- package/modules/hall-ui/public/goal-forest-render.js +50 -10
- package/modules/hall-ui/public/goals-page.js +19 -22
- package/modules/hall-ui/public/goals-render.js +6 -16
- package/modules/hall-ui/public/goals.css +13 -4
- package/modules/hall-ui/public/goals.html +22 -24
- package/modules/hall-ui/public/goals.states.json +19 -1
- package/modules/hall-ui/records/goal-dependency-forest.md +4 -3
- package/modules/platform-identity/invite-authz.js +1 -1
- package/modules/provisioning/catalog-bridge.js +1 -1
- package/modules/provisioning/provisioning.js +1 -1
- package/modules/provisioning/route-helpers.js +50 -0
- package/modules/provisioning/routes/disconnect.js +6 -5
- package/modules/provisioning/routes/env-manifest.js +11 -27
- package/modules/provisioning/routes/provisioning.js +58 -75
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +14 -0
- package/scripts/gds/module-assess-tests.js +40 -11
- package/scripts/gds/module-assess-version.js +10 -2
- package/scripts/gds/module-test-runner/README.md +44 -0
- package/scripts/gds/module-test-runner/module-tests.yml +87 -0
- package/scripts/gds/module-test-runner.js +358 -0
- package/src/module-api.js +1 -1
- package/tests/goal_forest_lib.mjs +28 -0
- package/tests/goal_forest_page.mjs +106 -11
- package/tests/module_assess_price_parity.mjs +2 -2
- package/tests/module_assess_score.mjs +13 -3
- package/tests/module_test_runner.mjs +294 -0
- package/tests/project_catalog_routes.mjs +1 -1
- package/tests/provisioning_module_selection.mjs +1 -1
- package/tests/provisioning_project_owner_gate.mjs +271 -0
|
@@ -5,12 +5,12 @@
|
|
|
5
5
|
// header), so a new self-contained feature is its own file — the same
|
|
6
6
|
// separation goal-map.js already keeps from goals-render.js, one file down.
|
|
7
7
|
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
// the
|
|
12
|
-
//
|
|
13
|
-
//
|
|
8
|
+
// Two surfaces (task 1004738): the band's compact Dependencies card, in the
|
|
9
|
+
// slot Conflicts used to hold — a summary, the tasks to ship first, the
|
|
10
|
+
// conflicts count — and the full picture behind it at #/goal/N/dependencies,
|
|
11
|
+
// where the forest needs real width. The full view always draws the whole
|
|
12
|
+
// forest returned by GET /goals/:id/dependency-forest (ADR 0227 §3: what it
|
|
13
|
+
// omits is exactly what has nothing to connect it). The model + layout are goal-forest-lib.js
|
|
14
14
|
// (pure); this file only draws what it returns, with createElementNS +
|
|
15
15
|
// textContent — the atlas / goal-map.js idiom — so a task title never
|
|
16
16
|
// becomes markup.
|
|
@@ -18,8 +18,9 @@
|
|
|
18
18
|
// A factory, the goals-render.js precedent, because `state` keeps changing
|
|
19
19
|
// after load: `createGoalForestRender(ctx)` takes the SAME `{ API, state,
|
|
20
20
|
// sameId, getJSON }` slice of ctx goals-page.js already builds, and returns
|
|
21
|
-
// `{ loadDependencyForest, renderDependencyForest }` for
|
|
22
|
-
// call
|
|
21
|
+
// `{ loadDependencyForest, renderDependencyForest, renderDependencyCard }` for
|
|
22
|
+
// goals-render.js to call (the card again whenever the conflicts land) and for
|
|
23
|
+
// tests/goal_forest_page.mjs to call directly.
|
|
23
24
|
(function () {
|
|
24
25
|
'use strict';
|
|
25
26
|
|
|
@@ -103,7 +104,44 @@
|
|
|
103
104
|
return svg;
|
|
104
105
|
}
|
|
105
106
|
|
|
107
|
+
const SHIP_FIRST = 3;
|
|
108
|
+
const taskHref = (id) => `#/task/${encodeURIComponent(id)}`;
|
|
109
|
+
// The band card. Reads state.conflictPairs too, so goals-render.js calls it
|
|
110
|
+
// again when the conflicts land; null there (loading, or a failed read)
|
|
111
|
+
// draws no conflicts line rather than a guess.
|
|
112
|
+
function renderDependencyCard() {
|
|
113
|
+
const lede = $('#goal-deps-card-lede');
|
|
114
|
+
const body = $('#goal-deps-card-body');
|
|
115
|
+
if (!lede || !body) return;
|
|
116
|
+
const a = state.forest && !state.forestError ? lib.analyse(state.forest) : null;
|
|
117
|
+
const n = a ? a.order.length : 0;
|
|
118
|
+
const pill = $('#goal-deps-card-count');
|
|
119
|
+
if (pill) { pill.hidden = !n; pill.textContent = n ? String(n) : ''; }
|
|
120
|
+
if (state.forestError) lede.textContent = 'Couldn’t load this goal’s task dependencies right now.';
|
|
121
|
+
else lede.textContent = a ? lib.brief(a) : 'Reading…';
|
|
122
|
+
const first = a ? lib.shipFirst(a, SHIP_FIRST) : [];
|
|
123
|
+
const pairs = Array.isArray(state.conflictPairs) ? state.conflictPairs : null;
|
|
124
|
+
body.innerHTML = (first.length
|
|
125
|
+
? `<p class="gf-brief__h">Ship these first</p><ul class="gf-brief">${first.map((r) => `<li class="gf-brief__row">
|
|
126
|
+
<a class="lb-name gf-brief__task" href="${taskHref(r.id)}" title="#${escapeHtml(r.id)} ${escapeHtml(r.title)}">#${escapeHtml(r.id)} ${escapeHtml(r.title)}</a>
|
|
127
|
+
<span class="gf-brief__n">unblocks ${r.unblocks}</span></li>`).join('')}</ul>`
|
|
128
|
+
: '')
|
|
129
|
+
+ (pairs
|
|
130
|
+
? `<p class="gf-brief__conflicts${pairs.length ? ' gf-brief__conflicts--live' : ''}">${pairs.length
|
|
131
|
+
? `${escapeHtml(lib.plural(pairs.length, 'file conflict'))} between open tasks.`
|
|
132
|
+
: 'No file conflicts between open tasks.'}</p>`
|
|
133
|
+
: '');
|
|
134
|
+
const foot = $('#goal-deps-card-foot');
|
|
135
|
+
if (!foot) return;
|
|
136
|
+
foot.hidden = !((n || (pairs && pairs.length)) && state.current != null);
|
|
137
|
+
foot.innerHTML = foot.hidden ? ''
|
|
138
|
+
: `<a class="pill-link" href="#/goal/${encodeURIComponent(state.current)}/dependencies">See the full picture →</a>`;
|
|
139
|
+
}
|
|
140
|
+
|
|
106
141
|
function renderDependencyForest() {
|
|
142
|
+
renderDependencyCard();
|
|
143
|
+
const back = $('#goal-deps-back');
|
|
144
|
+
if (back && state.current != null) back.setAttribute('href', `#/goal/${encodeURIComponent(state.current)}`);
|
|
107
145
|
const lede = $('#goal-deps-lede');
|
|
108
146
|
const body = $('#goal-deps-body');
|
|
109
147
|
if (!lede || !body) return;
|
|
@@ -130,8 +168,10 @@
|
|
|
130
168
|
setCount('#goal-deps-count', a.order.length);
|
|
131
169
|
lede.innerHTML = `${escapeHtml(lib.describe(a))} An arrow runs from the task that has to ship first to the task that waits on it. `
|
|
132
170
|
+ 'Every task here, and every dependency, is also in the table below.';
|
|
171
|
+
// The picture is drawn only while its own view is open: on the goal page
|
|
172
|
+
// the card is all that shows, and a forest drawn hidden cannot measure its titles.
|
|
173
|
+
if (state.view !== 'dependencies') { body.innerHTML = ''; return; }
|
|
133
174
|
const rows = lib.tableRows(a);
|
|
134
|
-
const taskHref = (id) => `#/task/${encodeURIComponent(id)}`;
|
|
135
175
|
const depList = (list) => (list.length
|
|
136
176
|
? `<ul class="gf-deps">${list.map((x) => `<li><a href="${taskHref(x.id)}">#${escapeHtml(x.id)} ${escapeHtml(x.title)}</a></li>`).join('')}</ul>`
|
|
137
177
|
: '<span class="gf-none">—</span>');
|
|
@@ -178,6 +218,6 @@
|
|
|
178
218
|
}
|
|
179
219
|
}
|
|
180
220
|
|
|
181
|
-
return { loadDependencyForest, renderDependencyForest };
|
|
221
|
+
return { loadDependencyForest, renderDependencyForest, renderDependencyCard };
|
|
182
222
|
};
|
|
183
223
|
})();
|
|
@@ -58,8 +58,8 @@
|
|
|
58
58
|
me: null,
|
|
59
59
|
goals: [], // list rows
|
|
60
60
|
details: new Map(), // goal_id -> {goal, members, criteria}
|
|
61
|
-
current: null, // open goal id (detail
|
|
62
|
-
view: 'list', // task 1828 — 'list' | 'detail' | '
|
|
61
|
+
current: null, // open goal id (detail or a full view) or null (list)
|
|
62
|
+
view: 'list', // task 1828 — 'list' | 'detail' | 'dependencies' | 'members' | 'completion'
|
|
63
63
|
inviteOpen: false, // task 1762 — the invite form's open/closed state
|
|
64
64
|
directory: null, // task 1877 — GET /builders/directory, fetched once and cached
|
|
65
65
|
// task 1828 — per-table column sort. by=null → natural order (todo: server
|
|
@@ -562,10 +562,8 @@
|
|
|
562
562
|
// full view at #/goal/N/<view> (the shared pattern behind Conflicts, Members,
|
|
563
563
|
// and Completion). Hidden when nothing is truncated.
|
|
564
564
|
|
|
565
|
-
// task
|
|
566
|
-
//
|
|
567
|
-
|
|
568
|
-
// task 1828 — the standalone #/goal/N/conflicts view: the whole set, no cap.
|
|
565
|
+
// task 1004738 — the conflict set renders on the #/goal/N/dependencies view;
|
|
566
|
+
// the band's Dependencies card carries its count.
|
|
569
567
|
|
|
570
568
|
/* ----- Completion (band column + #/goal/N/completion full view) ----- */
|
|
571
569
|
|
|
@@ -727,16 +725,16 @@
|
|
|
727
725
|
setWorkTab('todo');
|
|
728
726
|
workTabs?.setCount('todo', null);
|
|
729
727
|
workTabs?.setCount('done', null);
|
|
730
|
-
['#goal-conflicts-
|
|
728
|
+
['#goal-conflicts-view-count',
|
|
731
729
|
'#goal-members-count', '#goal-members-view-count', '#goal-completion-count'].forEach((sel) => {
|
|
732
730
|
const el = $(sel);
|
|
733
731
|
if (el) { el.hidden = true; el.textContent = ''; }
|
|
734
732
|
});
|
|
735
|
-
['#goal-todo-pager', '#goal-done-pager', '#goal-
|
|
733
|
+
['#goal-todo-pager', '#goal-done-pager', '#goal-members-foot', '#goal-completion-foot'].forEach((sel) => {
|
|
736
734
|
const el = $(sel);
|
|
737
735
|
if (el) el.hidden = true;
|
|
738
736
|
});
|
|
739
|
-
|
|
737
|
+
$('#goal-conflicts-view-card')?.classList.remove('goal-conflicts-card--live');
|
|
740
738
|
reflectSortHeaders('done');
|
|
741
739
|
// task 1002425 — To do is a card list now; Completed stays a table. Show a
|
|
742
740
|
// placeholder in the card list + skeleton rows in the Completed table while
|
|
@@ -749,25 +747,23 @@
|
|
|
749
747
|
if (todoCount) todoCount.textContent = '';
|
|
750
748
|
const bulkBar = $('#goal-bulk-toolbar');
|
|
751
749
|
if (bulkBar) bulkBar.hidden = true;
|
|
752
|
-
const conflicts = $('#goal-conflicts');
|
|
753
|
-
if (conflicts) conflicts.innerHTML = '<span class="kit-skel"></span>';
|
|
754
750
|
const conflictsFull = $('#goal-conflicts-full');
|
|
755
751
|
if (conflictsFull) conflictsFull.innerHTML = '<span class="kit-skel"></span>';
|
|
756
752
|
}
|
|
757
753
|
|
|
758
754
|
// task 1828 — views sharing this page: the list, a goal's detail, and the
|
|
759
|
-
// goal's three full views (
|
|
760
|
-
// from goalId (null → list).
|
|
761
|
-
const SUB_VIEWS = ['
|
|
755
|
+
// goal's three full views (dependencies / members / completion). `view`
|
|
756
|
+
// defaults from goalId (null → list).
|
|
757
|
+
const SUB_VIEWS = ['dependencies', 'members', 'completion'];
|
|
762
758
|
const VIEW_SECTION = {
|
|
763
759
|
list: '#goals-list-view',
|
|
764
760
|
detail: '#goal-detail-view',
|
|
765
|
-
|
|
761
|
+
dependencies: '#goal-dependencies-view',
|
|
766
762
|
members: '#goal-members-view',
|
|
767
763
|
completion: '#goal-completion-view',
|
|
768
764
|
};
|
|
769
765
|
const SUB_SUBTITLE = {
|
|
770
|
-
|
|
766
|
+
dependencies: 'Goal dependencies — which tasks wait on which, and which open tasks touch the same files.',
|
|
771
767
|
members: 'Goal members — who’s on this goal and what they’ve contributed to it.',
|
|
772
768
|
completion: 'Goal completion — every done-when criterion, with its full detail.',
|
|
773
769
|
};
|
|
@@ -787,7 +783,7 @@
|
|
|
787
783
|
if (sub) {
|
|
788
784
|
sub.textContent = view === 'list'
|
|
789
785
|
? 'Shared goals: progress, members, and the work remaining.'
|
|
790
|
-
: (SUB_SUBTITLE[view] || 'Goal detail — completion, members,
|
|
786
|
+
: (SUB_SUBTITLE[view] || 'Goal detail — completion, members, dependencies, and the work queue.');
|
|
791
787
|
}
|
|
792
788
|
window.scrollTo({ top: 0 });
|
|
793
789
|
}
|
|
@@ -804,14 +800,15 @@
|
|
|
804
800
|
if (state.current == null) renderList();
|
|
805
801
|
return;
|
|
806
802
|
}
|
|
807
|
-
// task 1828 — a goal's dedicated full views (
|
|
803
|
+
// task 1828 — a goal's dedicated full views (dependencies / members /
|
|
808
804
|
// completion). renderDetail renders all three full views + kicks
|
|
809
805
|
// loadGoalTasks, so a cold load straight to one still fills in once data
|
|
810
|
-
// lands (member stats / work / conflicts refresh async).
|
|
811
|
-
|
|
806
|
+
// lands (member stats / work / conflicts refresh async). /conflicts is the
|
|
807
|
+
// pre-1004738 name, still linked from the attention inbox.
|
|
808
|
+
const mSub = /^#\/goal\/(\d+)\/(dependencies|conflicts|members|completion)$/.exec(hash);
|
|
812
809
|
if (mSub) {
|
|
813
810
|
const id = mSub[1];
|
|
814
|
-
const view = mSub[2];
|
|
811
|
+
const view = mSub[2] === 'conflicts' ? 'dependencies' : mSub[2];
|
|
815
812
|
try {
|
|
816
813
|
await loadDetail(id);
|
|
817
814
|
setView(id, view);
|
|
@@ -999,7 +996,7 @@
|
|
|
999
996
|
}
|
|
1000
997
|
$('#goal-back')?.addEventListener('click', (e) => { e.preventDefault(); window.location.hash = ''; });
|
|
1001
998
|
// task 1828 — "← Back to goal" from any of the full views.
|
|
1002
|
-
['#goal-
|
|
999
|
+
['#goal-deps-back', '#goal-members-back', '#goal-completion-back'].forEach((sel) => {
|
|
1003
1000
|
$(sel)?.addEventListener('click', (e) => {
|
|
1004
1001
|
e.preventDefault();
|
|
1005
1002
|
if (state.current != null) window.location.hash = `#/goal/${state.current}`;
|
|
@@ -34,10 +34,9 @@
|
|
|
34
34
|
// still draws its criteria (state by satisfied/open, no UAT controls).
|
|
35
35
|
const uatUi = window.OTBCriterionUat || { isAwaiting: () => false, badgesHtml: () => '', actionHtml: () => '' };
|
|
36
36
|
|
|
37
|
-
|
|
38
|
-
//
|
|
39
|
-
//
|
|
40
|
-
// more members, more criteria). Conflicts uses CONFLICT_CAP above.
|
|
37
|
+
// task 1828 — the band's Completion / Members columns each cap their list and
|
|
38
|
+
// offer a "View all →" into a dedicated full view (these grow: more members,
|
|
39
|
+
// more criteria). The third column is the Dependencies card (task 1004738).
|
|
41
40
|
const MEMBER_CAP = 5;
|
|
42
41
|
const CRITERIA_CAP = 4;
|
|
43
42
|
// task 1932/1956 → kit (task 2171): the goal-card grid + the goal-detail Work
|
|
@@ -620,24 +619,15 @@
|
|
|
620
619
|
foot.innerHTML = '';
|
|
621
620
|
}
|
|
622
621
|
}
|
|
622
|
+
// task 1004738 — the whole conflict set lives on the dependencies view; the
|
|
623
|
+
// band's Dependencies card carries only its count.
|
|
623
624
|
function renderConflicts() {
|
|
624
|
-
const pairs = state.conflictPairs;
|
|
625
|
-
const card = $('#goal-conflicts-card');
|
|
626
|
-
fillConflicts($('#goal-conflicts'), pairs == null ? null : pairs.slice(0, CONFLICT_CAP));
|
|
627
|
-
const total = pairs ? pairs.length : 0;
|
|
628
|
-
renderViewAllFoot('#goal-conflicts-foot', total, CONFLICT_CAP, 'conflicts', 'conflicts');
|
|
629
|
-
setCount('#goal-conflicts-count', total);
|
|
630
|
-
card?.classList.toggle('goal-conflicts-card--live', total > 0);
|
|
631
|
-
renderConflictsFull();
|
|
632
|
-
}
|
|
633
|
-
function renderConflictsFull() {
|
|
634
625
|
const pairs = state.conflictPairs;
|
|
635
626
|
const total = pairs ? pairs.length : 0;
|
|
636
627
|
fillConflicts($('#goal-conflicts-full'), pairs);
|
|
637
628
|
setCount('#goal-conflicts-view-count', total);
|
|
638
629
|
$('#goal-conflicts-view-card')?.classList.toggle('goal-conflicts-card--live', total > 0);
|
|
639
|
-
|
|
640
|
-
if (back && state.current != null) back.setAttribute('href', `#/goal/${state.current}`);
|
|
630
|
+
forestRender.renderDependencyCard();
|
|
641
631
|
}
|
|
642
632
|
// task 1003480 — the per-goal task dependency forest is drawn by its own
|
|
643
633
|
// module now (goal-forest-render.js, carved out to keep this file under the
|
|
@@ -87,10 +87,8 @@
|
|
|
87
87
|
.goal-band__col + .goal-band__col { border-top: none; border-left: var(--line) solid var(--rule-soft); }
|
|
88
88
|
}
|
|
89
89
|
|
|
90
|
-
/* Live conflicts: the
|
|
91
|
-
|
|
92
|
-
(Count pills on these headers are the shared .count-pill.) */
|
|
93
|
-
.goal-conflicts-card--live { background: color-mix(in srgb, var(--warn) 6%, transparent); }
|
|
90
|
+
/* Live conflicts: the conflicts card on the dependencies view wears the amber
|
|
91
|
+
border (task 1004738 moved it there from the band). */
|
|
94
92
|
.goal-conflicts-card--live .count-pill { background: var(--warn-weak); color: var(--warn); }
|
|
95
93
|
#goal-conflicts-view-card.goal-conflicts-card--live { background: var(--bg-card); border-color: color-mix(in srgb, var(--warn) 42%, transparent); }
|
|
96
94
|
|
|
@@ -554,3 +552,14 @@
|
|
|
554
552
|
.gf-table .gf-deps { margin: 0; padding: 0; list-style: none; min-width: 12rem; }
|
|
555
553
|
.gf-table .gf-deps li { font-size: 12.5px; line-height: 1.4; }
|
|
556
554
|
.gf-none { color: var(--ink-faint); }
|
|
555
|
+
|
|
556
|
+
/* task 1004738 — the band's compact Dependencies card: a third of the width,
|
|
557
|
+
so each "ship first" title holds one line and ellipsises (the whole title is
|
|
558
|
+
its tooltip and the full view's table). */
|
|
559
|
+
.gf-brief__h { margin: 10px 0 2px; font-size: 12px; color: var(--ink-faint); }
|
|
560
|
+
.gf-brief { list-style: none; margin: 0; padding: 0; }
|
|
561
|
+
.gf-brief__row { display: flex; align-items: center; gap: 10px; font-size: 13px; }
|
|
562
|
+
.gf-brief__task { flex: 1; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
563
|
+
.gf-brief__n { flex-shrink: 0; font-size: 12px; color: var(--ink-faint); }
|
|
564
|
+
.gf-brief__conflicts { margin: 10px 0 0; font-size: 13px; color: var(--ink-soft); }
|
|
565
|
+
.gf-brief__conflicts--live { color: var(--warn); font-weight: 600; }
|
|
@@ -112,28 +112,18 @@
|
|
|
112
112
|
<ul class="member-list" id="goal-members"></ul>
|
|
113
113
|
<div class="goal-col__foot" id="goal-members-foot" hidden></div>
|
|
114
114
|
</div>
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
<
|
|
115
|
+
<!-- task 1004738 — the compact Dependencies card takes the slot
|
|
116
|
+
Conflicts held: a summary, the tasks to ship first and the
|
|
117
|
+
conflicts count. The forest, its table and the conflict list
|
|
118
|
+
are one click away on #/goal/N/dependencies. -->
|
|
119
|
+
<div class="goal-band__col" id="goal-deps-card">
|
|
120
|
+
<h2 class="scroll__h">Dependencies<span class="count-pill" id="goal-deps-card-count" hidden></span></h2>
|
|
121
|
+
<p class="scroll__lede" id="goal-deps-card-lede">Reading…</p>
|
|
122
|
+
<div id="goal-deps-card-body"></div>
|
|
123
|
+
<div class="goal-col__foot" id="goal-deps-card-foot" hidden></div>
|
|
122
124
|
</div>
|
|
123
125
|
</div>
|
|
124
126
|
|
|
125
|
-
<!-- task 1003480 (ADR 0227 §6 item 4 + §10.5): the per-goal task
|
|
126
|
-
dependency forest. Its own full-width section, not a band
|
|
127
|
-
column — a node-link picture needs real width, and (unlike
|
|
128
|
-
Completion/Members/Conflicts) this is bounded small BY DESIGN
|
|
129
|
-
(ADR 0227 §3's own finding), so there is no "view all" split:
|
|
130
|
-
what renders here is the whole forest, every time. -->
|
|
131
|
-
<div class="scroll" style="margin-top:20px" id="goal-deps-scroll">
|
|
132
|
-
<h2 class="scroll__h" id="goal-deps-h">Task dependencies<span class="count-pill" id="goal-deps-count" hidden></span></h2>
|
|
133
|
-
<p class="scroll__lede" id="goal-deps-lede">Reading…</p>
|
|
134
|
-
<div id="goal-deps-body"></div>
|
|
135
|
-
</div>
|
|
136
|
-
|
|
137
127
|
<div class="scroll" style="margin-top:20px">
|
|
138
128
|
<div class="goal-work__head">
|
|
139
129
|
<h2 class="scroll__h">Tasks</h2>
|
|
@@ -188,11 +178,19 @@
|
|
|
188
178
|
</div>
|
|
189
179
|
</section>
|
|
190
180
|
|
|
191
|
-
<!--
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
181
|
+
<!-- DEPENDENCIES full view (hash-routed: #/goal/N/dependencies; the old
|
|
182
|
+
#/goal/N/conflicts lands here too) — task 1004738. The per-goal task
|
|
183
|
+
dependency forest (task 1003480, ADR 0227 §6 item 4 + §10.5) gets
|
|
184
|
+
the page's full width a node-link picture needs, then the whole
|
|
185
|
+
conflict set (task 1828). -->
|
|
186
|
+
<section id="goal-dependencies-view" hidden aria-labelledby="goal-deps-h">
|
|
187
|
+
<p class="card-foot" style="margin:0 0 14px"><a href="#" id="goal-deps-back">← Back to goal</a></p>
|
|
188
|
+
<div class="scroll" style="margin:0" id="goal-deps-scroll">
|
|
189
|
+
<h2 class="scroll__h" id="goal-deps-h">Task dependencies<span class="count-pill" id="goal-deps-count" hidden></span></h2>
|
|
190
|
+
<p class="scroll__lede" id="goal-deps-lede">Reading…</p>
|
|
191
|
+
<div id="goal-deps-body"></div>
|
|
192
|
+
</div>
|
|
193
|
+
<div class="scroll" style="margin-top:20px" id="goal-conflicts-view-card">
|
|
196
194
|
<h2 class="scroll__h" id="goal-conflicts-view-h">Conflicts<span class="count-pill" id="goal-conflicts-view-count" hidden></span></h2>
|
|
197
195
|
<p class="scroll__lede">Open tasks whose declared files overlap — sequence these before two builders collide.</p>
|
|
198
196
|
<div id="goal-conflicts-full"></div>
|
|
@@ -122,7 +122,25 @@
|
|
|
122
122
|
],
|
|
123
123
|
"expect": {
|
|
124
124
|
"visible": [
|
|
125
|
-
"#goal-detail-view"
|
|
125
|
+
"#goal-detail-view",
|
|
126
|
+
"#goal-deps-card"
|
|
127
|
+
]
|
|
128
|
+
}
|
|
129
|
+
},
|
|
130
|
+
"dependencies": {
|
|
131
|
+
"auth": true,
|
|
132
|
+
"url": "/builders/goals#/goal/25/dependencies",
|
|
133
|
+
"actions": [
|
|
134
|
+
[
|
|
135
|
+
"wait",
|
|
136
|
+
1200
|
|
137
|
+
]
|
|
138
|
+
],
|
|
139
|
+
"expect": {
|
|
140
|
+
"visible": [
|
|
141
|
+
"#goal-dependencies-view",
|
|
142
|
+
"#goal-deps-scroll",
|
|
143
|
+
"#goal-conflicts-view-card"
|
|
126
144
|
]
|
|
127
145
|
}
|
|
128
146
|
},
|
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
# The per-goal dependency forest — on the Goals page (tasks 1003480, 1004200)
|
|
1
|
+
# The per-goal dependency forest — on the Goals page (tasks 1003480, 1004200, 1004738)
|
|
2
2
|
|
|
3
3
|
**THE PER-GOAL DEPENDENCY FOREST landed on the Goals page (task 1003480, goal 1000089; ADR 0227 §6 item 4 and its §10.5 amendment)** — the drill-down the goal map's own header pointed at (the map is the project sky since task 1004232): a task-level picture of which of THIS goal's tasks depend on which, from `GET /goals/:id/dependency-forest`. `goal-forest-lib.js` was the goal map's sibling — the same layered-layout idea (a task sits one column right of what it depends on, deterministic) at a smaller scale, since a per-goal forest is small BY MEASUREMENT (ADR 0227 §10.5: largest 127 tasks, median 9) rather than needing the goal map's cluster-and-shelf packing. Five things worth knowing:
|
|
4
4
|
|
|
5
|
-
- **
|
|
6
|
-
- **
|
|
5
|
+
- **A compact card on the goal page, the whole picture one click away (task 1004738, owner's call).** The forest first shipped as its own full-width block between the band and Tasks — ~840px for a five-task forest, with a table repeating every task — and the owner called it way too long. Now the band's third column (Conflicts' old slot) is a **Dependencies card**: `brief` (one line), up to three `shipFirst` tasks (open, blocked by nothing open, ranked by how many open tasks wait on them down the chain), the conflicts count, and "See the full picture →". The forest, its table and the whole conflict list live on **`#/goal/N/dependencies`**; `#/goal/N/conflicts` still lands there because the attention inbox links it. Conflicts folded in rather than kept beside it: both answer "what order do we build in".
|
|
6
|
+
- **The full view is never capped.** ADR 0227 §3's finding still holds — the tasks a forest omits are precisely the ones with nothing to connect them to — so the full view draws everything the server returns; only the card summarises. The figure is drawn with `createElementNS` + `textContent` (the atlas idiom — a task title never becomes markup) inside `.gf-scroll`, which names BOTH overflow axes (task 1004130), and it is drawn only while its view is open (`state.view === 'dependencies'`): the goal page shows just the card, so drawing there would be thrown away, and a forest drawn hidden cannot measure its titles.
|
|
7
|
+
- **The card reads two feeds.** It renders from `state.forest` AND `state.conflictPairs`, so `renderConflicts` in `goals-render.js` calls `renderDependencyCard` again when the conflicts land; a null `conflictPairs` (loading, or a failed read) draws no conflicts line rather than a zero.
|
|
7
8
|
- **The states are three, and distinct on purpose:** "Reading…" while the request is in flight, "No task in this goal depends on another task in this goal" for a real empty forest (never a blank canvas), and "Couldn't load…" only for an actual failure — a redirecting-to-auth error is swallowed silently instead, the `loadGoalTasks` precedent, because the page is already navigating away.
|
|
8
9
|
- **`loadDependencyForest`/`renderDependencyForest` are exported from `createGoalsRender`'s return** specifically so `tests/goal_forest_page.mjs` can call them directly against a minimal DOM stub, without needing the much larger fixture a full `renderDetail()` boot would need for members/completion/invites this task never touches.
|
|
9
10
|
- **The harness fixture is a single generic `goals__dependency-forest.json`** (task ids real, drawn from goal 25's own rows in `tasks.json`) — every goal id in the harness sees the same forest, which is fine for looking at the picture and not meant to describe any goal's real dependencies.
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
// .owner_github_id` and `platform_identity_projects.owner_github_id` are ATTRIBUTION, not
|
|
19
19
|
// authority — their own migration says so and ADR 0238 restates it. The authority is
|
|
20
20
|
// `provisioning_instances.owner_builder_id`, the fact the manage view already trusts and
|
|
21
|
-
// `
|
|
21
|
+
// `requireProjectOwner` already enforces. This module may not import provisioning (ADR 0083's
|
|
22
22
|
// one-way rule), so the fact travels on the kernel port `provisioning.projectOwnedBy` —
|
|
23
23
|
// resolveOptional and NOT declared in `consumes` (the `provisioning.projectPlanets`
|
|
24
24
|
// precedent): provisioning is default-off, and a hub running without it simply has no
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
// re-pointed address), which is the property a once-at-standup write can never have.
|
|
13
13
|
//
|
|
14
14
|
// IT GRANTS NOTHING. The catalog row is attribution (ADR 0238): ownership stays
|
|
15
|
-
// `provisioning_instances.owner_builder_id`, which is what `
|
|
15
|
+
// `provisioning_instances.owner_builder_id`, which is what `requireProjectOwner` already
|
|
16
16
|
// enforces on every route that calls this. The projection carries the owner's github id only
|
|
17
17
|
// so the hub can attribute the row and claim-check its own writes.
|
|
18
18
|
'use strict';
|
|
@@ -1402,7 +1402,7 @@ async function instanceStateForSlug(db, slug) {
|
|
|
1402
1402
|
// control-plane project a hub catalog row names? Lets a hub surface own-scope a project it
|
|
1403
1403
|
// did not create — without importing this module (ADR 0083) and without promoting the hub's
|
|
1404
1404
|
// own `owner_github_id` columns, which are attribution and never authority (ADR 0238). The
|
|
1405
|
-
// fact returned is the one `
|
|
1405
|
+
// fact returned is the one `requireProjectOwner` already enforces on every owner-facing
|
|
1406
1406
|
// route here: `provisioning_instances.owner_builder_id`.
|
|
1407
1407
|
//
|
|
1408
1408
|
// BOTH HALVES OF THE PROJECTION MUST AGREE, which is why this takes a slug AND a host rather
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// modules/provisioning/route-helpers.js — the one owner-or-archon gate every own-scoped
|
|
2
|
+
// /provisioning/instances/:id route mounts (task 1004159, ADR 0328 decision 2). It replaced
|
|
3
|
+
// three hand-written copies of the same rule, and the three routes that had only the owner
|
|
4
|
+
// half of it (teardown, domain, github-app).
|
|
5
|
+
'use strict';
|
|
6
|
+
|
|
7
|
+
const api = require('../../src/module-api');
|
|
8
|
+
const { failFrom } = require('./public-refusal');
|
|
9
|
+
|
|
10
|
+
const log = api.logger('provisioning');
|
|
11
|
+
|
|
12
|
+
function isProjectOwner(inst, builder) {
|
|
13
|
+
return String(inst.owner_builder_id) === String(builder.id);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// The audit actor for a request the gate admitted: the owner acts as themselves, and an
|
|
17
|
+
// archon on someone else's project is named, so the override never reads as the owner's act.
|
|
18
|
+
function projectActor(req) {
|
|
19
|
+
return isProjectOwner(req.project, req.builder) ? 'api:self' : `api:archon:${req.builder.id}`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// requireProjectOwner(loadFn, { archonOverride }) — mounted after requireBuilder, and spelled
|
|
23
|
+
// in the route's own middleware list so route-rank-check.js can see it (ADR 0328 §4). Loads
|
|
24
|
+
// the row with the caller's loader (`loadFn(id)`) and admits its owner or, by default, an
|
|
25
|
+
// archon. That is a plain RANK test on purpose: `provisioning.fleet.manage` floors at metic
|
|
26
|
+
// (ADR 0157), so gating on it would widen every route it guards.
|
|
27
|
+
//
|
|
28
|
+
// A missing row and someone else's row are the same 404, and the gate runs before the
|
|
29
|
+
// handler's body validator, so a stranger cannot tell a real id from a malformed request.
|
|
30
|
+
// The loaded row rides on `req.project`.
|
|
31
|
+
function requireProjectOwner(loadFn, { archonOverride = true } = {}) {
|
|
32
|
+
return async function projectOwnerGate(req, res, next) {
|
|
33
|
+
const id = api.parseId(req, res); // sends 400 bad_id + returns null on failure
|
|
34
|
+
if (!id) return;
|
|
35
|
+
let inst;
|
|
36
|
+
try {
|
|
37
|
+
inst = await loadFn(id);
|
|
38
|
+
} catch (err) {
|
|
39
|
+
log.error(`[provisioning] ${req.method} ${req.path} — project load`, err);
|
|
40
|
+
return failFrom(res, err, 'instance_read_failed');
|
|
41
|
+
}
|
|
42
|
+
const admitted = !!inst
|
|
43
|
+
&& (isProjectOwner(inst, req.builder) || (archonOverride && req.builder.rank === 'archon'));
|
|
44
|
+
if (!admitted) return res.fail('instance_not_found', 404);
|
|
45
|
+
req.project = inst;
|
|
46
|
+
return next();
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
module.exports = { requireProjectOwner, projectActor, isProjectOwner };
|
|
@@ -12,8 +12,10 @@ const { pool, validateOrRespond, parseId } = api;
|
|
|
12
12
|
const provisioning = require('../provisioning');
|
|
13
13
|
const disconnect = require('../disconnect');
|
|
14
14
|
const { failFrom } = require('../public-refusal');
|
|
15
|
+
const { requireProjectOwner, projectActor, isProjectOwner } = require('../route-helpers');
|
|
15
16
|
|
|
16
17
|
const log = api.logger('provisioning');
|
|
18
|
+
const loadInstance = (id) => provisioning.getInstanceById(pool, id);
|
|
17
19
|
|
|
18
20
|
module.exports = function buildDisconnectRouter() {
|
|
19
21
|
const router = express.Router();
|
|
@@ -24,19 +26,18 @@ module.exports = function buildDisconnectRouter() {
|
|
|
24
26
|
// this erases a database, so a replayed or scripted request must carry the same proof
|
|
25
27
|
// the button does. No status gate — any state may be disconnected; the queue refuses
|
|
26
28
|
// while another run is open (disconnect.enqueueDisconnect).
|
|
27
|
-
router.post('/provisioning/instances/:id/disconnect', api.requireBuilder, async (req, res) => {
|
|
29
|
+
router.post('/provisioning/instances/:id/disconnect', api.requireBuilder, requireProjectOwner(loadInstance), async (req, res) => {
|
|
28
30
|
const id = parseId(req, res); // (req, res) — parseId sends 400 bad_id + returns null on failure
|
|
29
31
|
if (!id) return;
|
|
30
32
|
if (validateOrRespond(req, res, { confirm_slug: { required: true, type: 'string', maxLength: 40 } })) return;
|
|
31
33
|
try {
|
|
32
|
-
const inst =
|
|
33
|
-
const isOwner =
|
|
34
|
-
if (!inst || (!isOwner && req.builder.rank !== 'archon')) return res.fail('instance_not_found', 404);
|
|
34
|
+
const inst = req.project;
|
|
35
|
+
const isOwner = isProjectOwner(inst, req.builder);
|
|
35
36
|
if (String(req.body.confirm_slug).trim().toLowerCase() !== inst.slug) {
|
|
36
37
|
return res.fail('confirm_mismatch', { status: 400,
|
|
37
38
|
message: `Type the project's name exactly — ${inst.slug} — to disconnect it. Nothing was changed.` });
|
|
38
39
|
}
|
|
39
|
-
const actor =
|
|
40
|
+
const actor = projectActor(req);
|
|
40
41
|
const r = await disconnect.enqueueDisconnect(pool, id, actor);
|
|
41
42
|
if (r.conflict) {
|
|
42
43
|
return res.fail('intent_conflict', { status: 409,
|
|
@@ -21,26 +21,14 @@
|
|
|
21
21
|
|
|
22
22
|
const express = require('express');
|
|
23
23
|
const api = require('../../../src/module-api');
|
|
24
|
-
const { pool, validateOrRespond
|
|
24
|
+
const { pool, validateOrRespond } = api;
|
|
25
25
|
const provisioning = require('../provisioning');
|
|
26
26
|
const envManifest = require('../env-manifest');
|
|
27
27
|
const { failFrom } = require('../public-refusal');
|
|
28
|
+
const { requireProjectOwner } = require('../route-helpers');
|
|
28
29
|
|
|
29
30
|
const log = api.logger('provisioning');
|
|
30
|
-
|
|
31
|
-
// Resolve the project and decide whether this caller may see it. Returns the instance, or
|
|
32
|
-
// null having already sent the 404 — the same own-or-archon rule as ./disconnect.js.
|
|
33
|
-
async function resolveOwnInstance(req, res) {
|
|
34
|
-
const id = parseId(req, res); // sends 400 bad_id + returns null on failure
|
|
35
|
-
if (!id) return null;
|
|
36
|
-
const inst = await provisioning.getInstanceById(pool, id);
|
|
37
|
-
const isOwner = !!inst && String(inst.owner_builder_id) === String(req.builder.id);
|
|
38
|
-
if (!inst || (!isOwner && req.builder.rank !== 'archon')) {
|
|
39
|
-
res.fail('instance_not_found', 404);
|
|
40
|
-
return null;
|
|
41
|
-
}
|
|
42
|
-
return inst;
|
|
43
|
-
}
|
|
31
|
+
const loadInstance = (id) => provisioning.getInstanceById(pool, id);
|
|
44
32
|
|
|
45
33
|
// The `:varId` path segment. parseId reads `:id`, which is the PROJECT here, so the
|
|
46
34
|
// declaration's id is parsed separately. A non-numeric id is a 400 naming the field
|
|
@@ -103,10 +91,9 @@ module.exports = function buildEnvManifestRouter() {
|
|
|
103
91
|
|
|
104
92
|
// GET — the declared surface. This is the route a BUILDER SESSION reads to learn which
|
|
105
93
|
// variables a project has, which is the whole reason the manifest exists.
|
|
106
|
-
router.get('/provisioning/instances/:id/env-manifest', api.requireBuilder, async (req, res) => {
|
|
94
|
+
router.get('/provisioning/instances/:id/env-manifest', api.requireBuilder, requireProjectOwner(loadInstance), async (req, res) => {
|
|
107
95
|
try {
|
|
108
|
-
const inst =
|
|
109
|
-
if (!inst) return;
|
|
96
|
+
const inst = req.project;
|
|
110
97
|
const vars = await envManifest.listEnvVars(pool, inst.id);
|
|
111
98
|
res.json({
|
|
112
99
|
instance: { id: String(inst.id), slug: inst.slug, hosting_shape: inst.hosting_shape },
|
|
@@ -126,7 +113,7 @@ module.exports = function buildEnvManifestRouter() {
|
|
|
126
113
|
|
|
127
114
|
// POST — declare a variable, or re-declare one that exists (an edit, not a duplicate:
|
|
128
115
|
// see ../env-manifest.js's declareEnvVar).
|
|
129
|
-
router.post('/provisioning/instances/:id/env-manifest', api.requireBuilder, async (req, res) => {
|
|
116
|
+
router.post('/provisioning/instances/:id/env-manifest', api.requireBuilder, requireProjectOwner(loadInstance), async (req, res) => {
|
|
130
117
|
if (refusesValue(req, res)) return; // before the strict validator — see refusesValue
|
|
131
118
|
if (validateOrRespond(req, res, {
|
|
132
119
|
name: { required: true, type: 'string', maxLength: envManifest.ENV_NAME_MAX },
|
|
@@ -135,8 +122,7 @@ module.exports = function buildEnvManifestRouter() {
|
|
|
135
122
|
required: { type: 'boolean' },
|
|
136
123
|
})) return;
|
|
137
124
|
try {
|
|
138
|
-
const inst =
|
|
139
|
-
if (!inst) return;
|
|
125
|
+
const inst = req.project;
|
|
140
126
|
if (badDeclaration(req, res, { nameRequired: true })) return;
|
|
141
127
|
const { row, created } = await envManifest.declareEnvVar(pool, {
|
|
142
128
|
instanceId: inst.id,
|
|
@@ -154,7 +140,7 @@ module.exports = function buildEnvManifestRouter() {
|
|
|
154
140
|
|
|
155
141
|
// PATCH — edit one declaration. Merge-patch: an omitted field is left alone, so the
|
|
156
142
|
// hall can fix a description without restating the environments.
|
|
157
|
-
router.patch('/provisioning/instances/:id/env-manifest/:varId', api.requireBuilder, async (req, res) => {
|
|
143
|
+
router.patch('/provisioning/instances/:id/env-manifest/:varId', api.requireBuilder, requireProjectOwner(loadInstance), async (req, res) => {
|
|
158
144
|
if (refusesValue(req, res)) return; // before the strict validator — see refusesValue
|
|
159
145
|
if (validateOrRespond(req, res, {
|
|
160
146
|
name: { type: 'string', maxLength: envManifest.ENV_NAME_MAX },
|
|
@@ -163,8 +149,7 @@ module.exports = function buildEnvManifestRouter() {
|
|
|
163
149
|
required: { type: 'boolean' },
|
|
164
150
|
})) return;
|
|
165
151
|
try {
|
|
166
|
-
const inst =
|
|
167
|
-
if (!inst) return;
|
|
152
|
+
const inst = req.project;
|
|
168
153
|
const varId = parseVarId(req, res);
|
|
169
154
|
if (!varId) return;
|
|
170
155
|
if (badDeclaration(req, res, { nameRequired: false })) return;
|
|
@@ -182,10 +167,9 @@ module.exports = function buildEnvManifestRouter() {
|
|
|
182
167
|
// DELETE — remove one declaration. Nothing outside this table is affected: the value,
|
|
183
168
|
// wherever it lives, is untouched, and the response says so rather than letting an
|
|
184
169
|
// owner believe removing the name removed the secret.
|
|
185
|
-
router.delete('/provisioning/instances/:id/env-manifest/:varId', api.requireBuilder, async (req, res) => {
|
|
170
|
+
router.delete('/provisioning/instances/:id/env-manifest/:varId', api.requireBuilder, requireProjectOwner(loadInstance), async (req, res) => {
|
|
186
171
|
try {
|
|
187
|
-
const inst =
|
|
188
|
-
if (!inst) return;
|
|
172
|
+
const inst = req.project;
|
|
189
173
|
const varId = parseVarId(req, res);
|
|
190
174
|
if (!varId) return;
|
|
191
175
|
const removed = await envManifest.removeEnvVar(pool, inst.id, varId);
|