@bongos/core 1.19.807 → 1.19.809
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 +27 -17
- package/docs/copy-inventory.md +58 -38
- package/docs/copy-registry.json +226 -46
- package/docs/module-api-changelog.md +4 -0
- package/modules/hall-ui/public/record.css +23 -0
- package/modules/hall-ui/public/task.js +224 -7
- package/modules/lifecycle/abandon-leftovers.js +103 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/abandon.js +48 -0
- package/src/module-api.js +1 -1
- package/tests/abandon_leftovers.mjs +87 -0
- package/tests/hall_record_world.mjs +183 -1
|
@@ -356,6 +356,29 @@
|
|
|
356
356
|
|
|
357
357
|
/* ---- the actions --------------------------------------------------------- */
|
|
358
358
|
.rec-actions { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
|
|
359
|
+
|
|
360
|
+
/* task 1002858 — the shaping verbs on the task record.
|
|
361
|
+
|
|
362
|
+
The RETIRE row sits under the button row rather than in it, and is separated
|
|
363
|
+
by a rule: it is the one control on this page that destroys the plan instead
|
|
364
|
+
of advancing it, so it does not share a line with Ship it and Copy prompt. */
|
|
365
|
+
.rec-actions--retire { margin: 14px 0 0; padding: 12px 0 0; border-top: var(--line) solid var(--rule-soft); }
|
|
366
|
+
.td-btn--danger { color: var(--danger); border-color: var(--danger); }
|
|
367
|
+
.td-btn--danger:hover:not(:disabled) { background: var(--danger-weak); color: var(--danger); border-color: var(--danger); }
|
|
368
|
+
|
|
369
|
+
/* The × that cuts a dependency edge. Quiet until the row is hovered or it is
|
|
370
|
+
focused, because the list's job is to be READ — the control is for the rare
|
|
371
|
+
moment the chain is wrong, and a column of red crosses would say otherwise. */
|
|
372
|
+
.rec-dep__cut {
|
|
373
|
+
flex: none; margin-left: 4px; padding: 0 6px; min-width: 24px; line-height: 20px;
|
|
374
|
+
background: none; border: var(--line) solid transparent; border-radius: var(--radius-sm);
|
|
375
|
+
color: var(--ink-faint); font-size: 15px; cursor: pointer; opacity: 0;
|
|
376
|
+
}
|
|
377
|
+
.rec-deplist li:hover .rec-dep__cut, .rec-dep__cut:focus-visible { opacity: 1; }
|
|
378
|
+
.rec-dep__cut:hover:not(:disabled) { color: var(--danger); border-color: var(--danger); }
|
|
379
|
+
.rec-dep__cut:disabled { color: var(--ink-faint); cursor: default; opacity: 1; }
|
|
380
|
+
/* Touch has no hover, so the control must simply be there. */
|
|
381
|
+
@media (hover: none) { .rec-dep__cut { opacity: 1; } }
|
|
359
382
|
/* Where a lifecycle control would be but is not, the record says why in words.
|
|
360
383
|
Nothing here is pressable-or-live, so nothing here is warm. */
|
|
361
384
|
.rec-actnote { margin: 0; flex: 1 1 240px; font-size: 14px; color: var(--ink-soft); max-width: var(--measure); }
|
|
@@ -7,10 +7,18 @@
|
|
|
7
7
|
// the page itself (SIGNEDIN_BUILDERS_PAGE_RE + the two-segment TASK_DETAIL_PAGE_RE),
|
|
8
8
|
// so an unauthenticated visitor never reaches this script.
|
|
9
9
|
//
|
|
10
|
-
// READ
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
10
|
+
// NOT READ-ONLY ANY MORE, and this comment is the third thing to say so: task
|
|
11
|
+
// 751 opened the page read+navigation only, task 759 added Ship it / Confirm,
|
|
12
|
+
// task 1003063 added the goal move, and task 1002858 adds the task-SHAPING
|
|
13
|
+
// verbs — priority, dependency add/remove, and abandon. "Copy prompt" is still
|
|
14
|
+
// the only purely client-side control (a clipboard write, no server call).
|
|
15
|
+
//
|
|
16
|
+
// THE RULE EVERY WRITE ON THIS PAGE FOLLOWS (ADR 0016): the server owns every
|
|
17
|
+
// rank and ownership gate; this file only decides which control to SURFACE, and
|
|
18
|
+
// maps a refusal to a sentence. isSupervisor here is the client's read of the
|
|
19
|
+
// Metic floor those permissions share (task.edit, task.abandon,
|
|
20
|
+
// task.dependency.manage) — it is a display hint, never the gate. Claim/release
|
|
21
|
+
// from the browser is still the dependent follow-up task.
|
|
14
22
|
|
|
15
23
|
(() => {
|
|
16
24
|
'use strict';
|
|
@@ -21,6 +29,9 @@
|
|
|
21
29
|
// builds through unchanged; result-mode ({ status, ok, data }) preserves control flow 1:1.
|
|
22
30
|
const api = window.BongosClient.createClient({ baseUrl: '', credentials: 'same-origin', throwOnError: false });
|
|
23
31
|
const AUTH_NEEDED = 'auth-needed';
|
|
32
|
+
// task 1002858 — whether the retire disclosure is expanded. Module-level
|
|
33
|
+
// because renderTask() rebuilds #task-body wholesale on every call.
|
|
34
|
+
let abandonOpen = false;
|
|
24
35
|
const { escapeHtml, $, fmtDate, errCode, errMessage } = window.OTB; // task 1002462: envelope readers
|
|
25
36
|
const toast = (msg) => window.OTB.toast(msg, { duration: 2600 });
|
|
26
37
|
const curLabel = () => (window.__BRANDING__ && window.__BRANDING__.currency && window.__BRANDING__.currency.label) || 'credits';
|
|
@@ -84,6 +95,24 @@
|
|
|
84
95
|
return data;
|
|
85
96
|
}
|
|
86
97
|
|
|
98
|
+
// task 1002858: mutating call for removing a dependency edge. Same posture as
|
|
99
|
+
// postJSON/patchJSON — resolves to the parsed 2xx body, throws an Error carrying
|
|
100
|
+
// { status, data } so the caller can print the server's own refusal. DELETE
|
|
101
|
+
// carries no body by design; the edge to cut is named in the path.
|
|
102
|
+
async function delJSON(path) {
|
|
103
|
+
const res = await api.request('DELETE', path);
|
|
104
|
+
if (res.status === 401) { toAuth(); throw new Error('redirecting to auth'); }
|
|
105
|
+
let data = null;
|
|
106
|
+
try { data = res.data; } catch (_) { /* empty / non-JSON body */ }
|
|
107
|
+
if (!res.ok) {
|
|
108
|
+
const err = new Error(`${path} → ${res.status}`);
|
|
109
|
+
err.status = res.status;
|
|
110
|
+
err.data = data || {};
|
|
111
|
+
throw err;
|
|
112
|
+
}
|
|
113
|
+
return data;
|
|
114
|
+
}
|
|
115
|
+
|
|
87
116
|
// Map the server's typed lifecycle-error to a builder-readable line. Codes are
|
|
88
117
|
// exactly those POST /tasks/:id/ship + /confirm can return. The server is the
|
|
89
118
|
// authority (ADR 0016); the UI only surfaces the refusal.
|
|
@@ -235,11 +264,21 @@
|
|
|
235
264
|
return `<span class="rec-pill${tone ? ` rec-pill--${tone}` : ''}${extraClass ? ` ${extraClass}` : ''}">${escapeHtml(s)}</span>`;
|
|
236
265
|
}
|
|
237
266
|
|
|
238
|
-
|
|
267
|
+
// `removable` is passed ONLY for "Blocked by" (task 1002858), and the asymmetry
|
|
268
|
+
// is deliberate. DELETE /tasks/:id/dependencies/:depId removes the edge where
|
|
269
|
+
// THIS task depends on another, which is this record's own business. The
|
|
270
|
+
// "Blocks" list is the same edges seen from the far end: dropping one there
|
|
271
|
+
// would be editing a DIFFERENT task's dependency set from a page that is not
|
|
272
|
+
// showing it, with no sight of why that other task was made to wait. Open that
|
|
273
|
+
// task and remove it there.
|
|
274
|
+
function depList(kindWord, items, { removable = false } = {}) {
|
|
239
275
|
const rows = items.map((d) => {
|
|
240
276
|
const status = d.status ? statusPill(d.status, 'rec-pill--sm') : '';
|
|
277
|
+
const cut = removable
|
|
278
|
+
? `<button class="rec-dep__cut" type="button" data-cut-dep="${escapeHtml(String(d.id))}" title="Stop waiting on task ${escapeHtml(String(d.id))}" aria-label="Stop waiting on task ${escapeHtml(String(d.id))}">×</button>`
|
|
279
|
+
: '';
|
|
241
280
|
return `<li><a class="rec-dep" href="${hallBase()}/task/${escapeHtml(String(d.id))}">` +
|
|
242
|
-
`<b class="rec-dep__id">${escapeHtml(String(d.id))}</b>${escapeHtml(d.title || 'task')}</a>${status}</li>`;
|
|
281
|
+
`<b class="rec-dep__id">${escapeHtml(String(d.id))}</b>${escapeHtml(d.title || 'task')}</a>${status}${cut}</li>`;
|
|
243
282
|
}).join('');
|
|
244
283
|
return `<section><p class="rec-sec__h">${escapeHtml(kindWord)}</p><ul class="rec-deplist">${rows}</ul></section>`;
|
|
245
284
|
}
|
|
@@ -459,6 +498,21 @@
|
|
|
459
498
|
// gates read the same rank once, rather than risking drift between copies.
|
|
460
499
|
const rank = (viewer && viewer.rank) ? String(viewer.rank).toLowerCase() : '';
|
|
461
500
|
const isSupervisor = rank === 'metic' || rank === 'archon';
|
|
501
|
+
// task 1002858 — the SHAPING verbs (priority, dependencies, abandon) share one
|
|
502
|
+
// floor: task.edit, task.dependency.manage and task.abandon are all Metic+
|
|
503
|
+
// (ADR 0090; abandon re-ranked down from Archon by ADR 0152). A separate name
|
|
504
|
+
// from isSupervisor because they are separate ideas that merely agree today —
|
|
505
|
+
// if one of those permissions moves, this line moves and the lifecycle
|
|
506
|
+
// controls do not. It is a DISPLAY hint: the server re-checks every one.
|
|
507
|
+
//
|
|
508
|
+
// Goal LEAD status deliberately confers nothing here. Whether leading a goal
|
|
509
|
+
// should carry these permissions is a governance change that needs its own
|
|
510
|
+
// ADR, so this surfaces controls only for builders who already hold the atoms.
|
|
511
|
+
const canShape = isSupervisor;
|
|
512
|
+
// render() rebuilds #task-body wholesale, so a disclosure cannot keep its
|
|
513
|
+
// state in the DOM. This re-renders the SAME data without a refetch — the
|
|
514
|
+
// toggle is a view change, not a reason to ask the server anything.
|
|
515
|
+
const rerender = () => renderTask(t, lastClaim, viewer, goal, extras);
|
|
462
516
|
|
|
463
517
|
const parts = [];
|
|
464
518
|
parts.push(`<p class="rec-kind">Task ${statusPill(t.status)}</p>`);
|
|
@@ -520,6 +574,41 @@
|
|
|
520
574
|
`);
|
|
521
575
|
}
|
|
522
576
|
|
|
577
|
+
// task 1002858 — the two remaining SHAPING verbs, next to the goal move
|
|
578
|
+
// because they are the same kind of act: correcting how the work is routed
|
|
579
|
+
// and ordered, not moving it through its lifecycle.
|
|
580
|
+
//
|
|
581
|
+
// PRIORITY IS NOT COSMETIC, which is why it earns a control rather than a
|
|
582
|
+
// note: the DB weights version progress as `6 - COALESCE(priority, 5)` and
|
|
583
|
+
// the session optimizer orders claimable work by `priority ASC NULLS LAST`,
|
|
584
|
+
// so a wrong value at create time steers what every builder claims until
|
|
585
|
+
// someone fixes it. Until task 1003750 it was write-once at every rank and a
|
|
586
|
+
// whole-goal re-rank had to run as raw SQL. 1..5 mirrors tasks_priority_check;
|
|
587
|
+
// the empty option clears it back to unset, which is a real state (NULLS LAST)
|
|
588
|
+
// and not the same as P5.
|
|
589
|
+
if (canShape) {
|
|
590
|
+
const pOpts = ['', '1', '2', '3', '4', '5'].map((v) => {
|
|
591
|
+
const cur = t.priority == null ? '' : String(t.priority);
|
|
592
|
+
const label = v === '' ? '— unset —' : `P${v}`;
|
|
593
|
+
return `<option value="${v}"${v === cur ? ' selected' : ''}>${label}</option>`;
|
|
594
|
+
}).join('');
|
|
595
|
+
parts.push(`
|
|
596
|
+
<div class="td-form" id="shape-form">
|
|
597
|
+
<label class="td-field"><span>Priority</span>
|
|
598
|
+
<select id="priority-select">${pOpts}</select>
|
|
599
|
+
</label>
|
|
600
|
+
<label class="td-field"><span>Wait for task</span>
|
|
601
|
+
<input id="dep-add-input" type="number" min="1" step="1" inputmode="numeric" placeholder="task id">
|
|
602
|
+
</label>
|
|
603
|
+
<div class="td-actions">
|
|
604
|
+
<button type="button" class="td-btn" id="priority-btn">Set priority</button>
|
|
605
|
+
<button type="button" class="td-btn" id="dep-add-btn">Add dependency</button>
|
|
606
|
+
</div>
|
|
607
|
+
<p class="td-form-hint">A dependency keeps this task out of the claimable queue until that one ships. Remove one with the × beside it under “Blocked by”.</p>
|
|
608
|
+
</div>
|
|
609
|
+
`);
|
|
610
|
+
}
|
|
611
|
+
|
|
523
612
|
const creditVal = window.OTBBranding?.taskCreditValue?.(t) ?? t.credits_reward;
|
|
524
613
|
parts.push(figureBand([
|
|
525
614
|
[window.OTBBranding?.taskCreditLabel?.(t) ?? curLabel(), creditVal],
|
|
@@ -577,7 +666,7 @@
|
|
|
577
666
|
const deps = Array.isArray(t.dependencies) ? t.dependencies : [];
|
|
578
667
|
const dependents = Array.isArray(t.dependents) ? t.dependents : [];
|
|
579
668
|
if (deps.length || dependents.length) {
|
|
580
|
-
parts.push(`<div class="rec-sec rec-depcols">${deps.length ? depList('Blocked by', deps) : ''}${dependents.length ? depList('Blocks', dependents) : ''}</div>`);
|
|
669
|
+
parts.push(`<div class="rec-sec rec-depcols">${deps.length ? depList('Blocked by', deps, { removable: canShape }) : ''}${dependents.length ? depList('Blocks', dependents) : ''}</div>`);
|
|
581
670
|
}
|
|
582
671
|
|
|
583
672
|
if (t.shipped_at) {
|
|
@@ -614,6 +703,40 @@
|
|
|
614
703
|
lifecycle = `<p class="rec-actnote">This task has shipped and its credits have landed. Neither Ship it nor Confirm is available.</p>`;
|
|
615
704
|
}
|
|
616
705
|
|
|
706
|
+
// task 1002858 — RETIRE. Kept out of the button row and behind a disclosure
|
|
707
|
+
// because it is the one control here that destroys the plan rather than
|
|
708
|
+
// advancing it, and because the server demands a reason it can put in the
|
|
709
|
+
// audit log.
|
|
710
|
+
//
|
|
711
|
+
// THE TWO REFUSALS ARE PRE-ANSWERED IN WORDS RATHER THAN OFFERED AS A BUTTON
|
|
712
|
+
// THAT CANNOT WORK. A shipped task is immutable history, and a task with an
|
|
713
|
+
// active claim must be released first — both are 409s the server will raise
|
|
714
|
+
// anyway, and both are knowable from this record. Offering the control and
|
|
715
|
+
// letting it fail would teach the reader that the page guesses.
|
|
716
|
+
const claimIsLive = !!(lastClaim && !lastClaim.released_at);
|
|
717
|
+
let retire = '';
|
|
718
|
+
if (canShape && t.status === 'shipped') {
|
|
719
|
+
retire = '<p class="rec-actnote">A shipped task cannot be retired — it is immutable history now.</p>';
|
|
720
|
+
} else if (canShape && claimIsLive) {
|
|
721
|
+
retire = '<p class="rec-actnote">Someone is holding a claim on this task. It has to be released before the task can be retired.</p>';
|
|
722
|
+
} else if (canShape && t.status === 'abandoned') {
|
|
723
|
+
retire = '<p class="rec-actnote">This task is already retired. Promote restores it to the backlog.</p>';
|
|
724
|
+
} else if (canShape) {
|
|
725
|
+
retire = abandonOpen
|
|
726
|
+
? `
|
|
727
|
+
<div class="td-form" id="abandon-form">
|
|
728
|
+
<label class="td-field td-field--wide"><span>Why retire this task?</span>
|
|
729
|
+
<input id="abandon-reason" type="text" maxlength="2000" placeholder="e.g. superseded by task 1002891">
|
|
730
|
+
</label>
|
|
731
|
+
<div class="td-actions">
|
|
732
|
+
<button type="button" class="td-btn td-btn--danger" id="abandon-btn">Retire this task</button>
|
|
733
|
+
<button type="button" class="td-btn" id="abandon-cancel-btn">Cancel</button>
|
|
734
|
+
</div>
|
|
735
|
+
<p class="td-form-hint">The reason is recorded in the audit log. This is recoverable: a retired task can be restored to the backlog with Promote.</p>
|
|
736
|
+
</div>`
|
|
737
|
+
: '<button class="td-btn" type="button" id="abandon-open-btn">Retire this task…</button>';
|
|
738
|
+
}
|
|
739
|
+
|
|
617
740
|
// Copy prompt (the "ship link") — the same claim/build/ship prompt the Work
|
|
618
741
|
// Board hands an agent. Purely client-side (clipboard write, no server call).
|
|
619
742
|
parts.push(`
|
|
@@ -624,6 +747,7 @@
|
|
|
624
747
|
<button class="td-btn" type="button" id="copy-prompt-btn">Copy prompt</button>
|
|
625
748
|
${exitLinkHtml()}
|
|
626
749
|
</div>
|
|
750
|
+
${retire ? `<div class="rec-actions rec-actions--retire">${retire}</div>` : ''}
|
|
627
751
|
</section>
|
|
628
752
|
`);
|
|
629
753
|
|
|
@@ -660,6 +784,99 @@
|
|
|
660
784
|
// task 1003063 (R10): PATCH goal_id, then a full reload so the goal line, the
|
|
661
785
|
// picker's preselected option and everything else on the record re-render
|
|
662
786
|
// against the server's own state — no local patchwork of the DOM.
|
|
787
|
+
// task 1002858 — the shaping handlers. Each one: disable while in flight, send,
|
|
788
|
+
// then `load()` so the whole record re-renders against the SERVER's state
|
|
789
|
+
// rather than a local patch of the DOM (the idiom the goal move set below).
|
|
790
|
+
// Every refusal prints the server's own sentence where it has one — those are
|
|
791
|
+
// more specific than anything this page could invent, and one of them
|
|
792
|
+
// (dependency.cross_tier.manage, Archon-only under ADR 0157) names a gate this
|
|
793
|
+
// file deliberately does not model rather than re-deriving it client-side.
|
|
794
|
+
async function runWrite(btn, fn, okMsg) {
|
|
795
|
+
if (!btn || btn.disabled) return;
|
|
796
|
+
const orig = btn.textContent;
|
|
797
|
+
btn.disabled = true;
|
|
798
|
+
btn.textContent = '…';
|
|
799
|
+
try {
|
|
800
|
+
await fn();
|
|
801
|
+
toast(okMsg);
|
|
802
|
+
await load();
|
|
803
|
+
} catch (err) {
|
|
804
|
+
if (err && err.message === 'redirecting to auth') return;
|
|
805
|
+
toast(errMessage(err && err.data) || `That did not go through (${(err && err.status) || '?'}). Try again.`);
|
|
806
|
+
btn.disabled = false;
|
|
807
|
+
btn.textContent = orig;
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
const priorityBtn = $('#priority-btn');
|
|
812
|
+
if (priorityBtn) {
|
|
813
|
+
priorityBtn.addEventListener('click', () => {
|
|
814
|
+
const raw = $('#priority-select')?.value ?? '';
|
|
815
|
+
// '' is the unset option, and it is sent as null ON PURPOSE — "no priority"
|
|
816
|
+
// is a real state the optimizer sorts last (NULLS LAST), not a synonym for
|
|
817
|
+
// P5. Sending nothing at all would 400 as no_supported_fields.
|
|
818
|
+
const priority = raw === '' ? null : Number(raw);
|
|
819
|
+
if (priority !== null && !Number.isInteger(priority)) { toast('Pick a priority first.'); return; }
|
|
820
|
+
const was = t.priority == null ? 'unset' : `P${t.priority}`;
|
|
821
|
+
const now = priority == null ? 'unset' : `P${priority}`;
|
|
822
|
+
if (was === now) { toast(`Priority is already ${now}.`); return; }
|
|
823
|
+
runWrite(priorityBtn,
|
|
824
|
+
() => patchJSON(`${API}/tasks/${encodeURIComponent(t.id)}`, { priority }),
|
|
825
|
+
`Priority ${was} → ${now}.`);
|
|
826
|
+
});
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
const depAddBtn = $('#dep-add-btn');
|
|
830
|
+
if (depAddBtn) {
|
|
831
|
+
depAddBtn.addEventListener('click', () => {
|
|
832
|
+
const raw = $('#dep-add-input')?.value ?? '';
|
|
833
|
+
const depId = Number(raw);
|
|
834
|
+
if (!Number.isInteger(depId) || depId <= 0) { toast('Enter the id of the task this one should wait for.'); return; }
|
|
835
|
+
// Caught here only to say something better than the server's 'self_dep';
|
|
836
|
+
// the server still refuses it if this check is ever wrong.
|
|
837
|
+
if (String(depId) === String(t.id)) { toast('A task cannot wait for itself.'); return; }
|
|
838
|
+
runWrite(depAddBtn,
|
|
839
|
+
() => postJSON(`${API}/tasks/${encodeURIComponent(t.id)}/dependencies`, { depends_on_task_id: depId }),
|
|
840
|
+
`Task ${t.id} now waits for task ${depId}.`);
|
|
841
|
+
});
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
// Wired per row, because the × lives inside a list this function rebuilds on
|
|
845
|
+
// every render and the nodes are replaced with it.
|
|
846
|
+
for (const cut of document.querySelectorAll('[data-cut-dep]')) {
|
|
847
|
+
cut.addEventListener('click', () => {
|
|
848
|
+
const depId = cut.getAttribute('data-cut-dep');
|
|
849
|
+
runWrite(cut,
|
|
850
|
+
() => delJSON(`${API}/tasks/${encodeURIComponent(t.id)}/dependencies/${encodeURIComponent(depId)}`),
|
|
851
|
+
`Task ${t.id} no longer waits for task ${depId}.`);
|
|
852
|
+
});
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
const abandonOpenBtn = $('#abandon-open-btn');
|
|
856
|
+
if (abandonOpenBtn) {
|
|
857
|
+
abandonOpenBtn.addEventListener('click', () => { abandonOpen = true; rerender(); });
|
|
858
|
+
}
|
|
859
|
+
const abandonCancelBtn = $('#abandon-cancel-btn');
|
|
860
|
+
if (abandonCancelBtn) {
|
|
861
|
+
abandonCancelBtn.addEventListener('click', () => { abandonOpen = false; rerender(); });
|
|
862
|
+
}
|
|
863
|
+
const abandonBtn = $('#abandon-btn');
|
|
864
|
+
if (abandonBtn) {
|
|
865
|
+
abandonBtn.addEventListener('click', () => {
|
|
866
|
+
const reason = ($('#abandon-reason')?.value || '').trim();
|
|
867
|
+
// A REASON IS REQUIRED HERE THOUGH THE SERVER ACCEPTS AN EMPTY ONE. The
|
|
868
|
+
// value of the audit_log row is the sentence in it; a retire with a blank
|
|
869
|
+
// reason is the record noting that someone did this and declining to say
|
|
870
|
+
// why. Asking costs one line.
|
|
871
|
+
if (!reason) { toast('Say why it is being retired — the reason goes in the record.'); return; }
|
|
872
|
+
if (!window.confirm(`Retire task ${t.id}? It leaves the claimable queue. Promote restores it to the backlog.`)) return;
|
|
873
|
+
abandonOpen = false;
|
|
874
|
+
runWrite(abandonBtn,
|
|
875
|
+
() => postJSON(`${API}/tasks/${encodeURIComponent(t.id)}/abandon`, { reason }),
|
|
876
|
+
`Task ${t.id} retired. Promote restores it to the backlog.`);
|
|
877
|
+
});
|
|
878
|
+
}
|
|
879
|
+
|
|
663
880
|
const moveGoalBtn = $('#move-goal-btn');
|
|
664
881
|
if (moveGoalBtn) {
|
|
665
882
|
moveGoalBtn.addEventListener('click', async () => {
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// modules/lifecycle/abandon-leftovers.js — what a retired task leaves behind
|
|
4
|
+
// (task 1003779).
|
|
5
|
+
//
|
|
6
|
+
// THE DEFECT THIS EXISTS FOR. Abandoning a task retires the ROW and nothing else.
|
|
7
|
+
// Task 1002464 was abandoned carrying two commits on `task-1002464` that are not
|
|
8
|
+
// ancestors of origin/main, and an open PR. Nothing said so — not at the moment
|
|
9
|
+
// of abandonment, not afterwards — so the branch read as live work, and a
|
|
10
|
+
// follow-up filed off its grading pass (task 1003626) sat at `ready` asking for
|
|
11
|
+
// three nits in a function that exists nowhere on main. `sequence.js --check`
|
|
12
|
+
// called it "clear to run". The cost landed on whoever claimed it.
|
|
13
|
+
//
|
|
14
|
+
// SIBLING OF dead-deps.js, deliberately. That file makes an abandoned
|
|
15
|
+
// DEPENDENCY observable; this one makes abandoned CODE observable. Same posture:
|
|
16
|
+
// pure and git-free here so the CLI is the only place that shells out, and the
|
|
17
|
+
// rule can be tested without a repository.
|
|
18
|
+
//
|
|
19
|
+
// WHAT THIS IS NOT. It does not delete a branch, close a PR, or touch the
|
|
20
|
+
// remote. Retiring a row is reversible (POST /tasks/:id/promote); deleting the
|
|
21
|
+
// only copy of unmerged work is not, and which of the two a leftover branch
|
|
22
|
+
// deserves is a human call. This reports, loudly, with the commands to hand.
|
|
23
|
+
//
|
|
24
|
+
// WHY NOT source_ref, which the report suggested. The incident that motivated
|
|
25
|
+
// this task is the counter-example: 1003626's `source_ref` is `idea:1000935`,
|
|
26
|
+
// not the branch or task it was stranded behind — the relationship lived only in
|
|
27
|
+
// the prose of its description. A source_ref sweep would have found nothing.
|
|
28
|
+
// The branch is the artifact that actually exists, so the branch is what is
|
|
29
|
+
// checked.
|
|
30
|
+
|
|
31
|
+
// A branch belongs to task N if it is `task-N` or `task-N-<slug>` (the two
|
|
32
|
+
// shapes worktree.js produces). Anchored at both ends so task-1002450 never
|
|
33
|
+
// matches task-10024501 — a prefix match here would report another task's work
|
|
34
|
+
// as this one's leftovers, which is worse than reporting nothing.
|
|
35
|
+
function branchesForTask(taskId, branches) {
|
|
36
|
+
const id = String(taskId == null ? '' : taskId).trim();
|
|
37
|
+
if (!/^\d+$/.test(id)) return [];
|
|
38
|
+
const re = new RegExp(`^task-${id}(?:-[A-Za-z0-9._-]+)?$`);
|
|
39
|
+
// Deduped after stripping the remote prefix: a caller may pass the same branch
|
|
40
|
+
// as both 'task-N' and 'origin/task-N' (git branch -r vs ls-remote), and the
|
|
41
|
+
// notice would then tell a builder to delete one branch twice.
|
|
42
|
+
const seen = new Set();
|
|
43
|
+
for (const raw of branches || []) {
|
|
44
|
+
const b = String(raw || '').trim().replace(/^origin\//, '');
|
|
45
|
+
if (re.test(b)) seen.add(b);
|
|
46
|
+
}
|
|
47
|
+
return [...seen];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Pure: the leftover report for one task. `aheadCount(branch)` answers "how many
|
|
51
|
+
// commits does this branch carry that main does not" — a number, or null when it
|
|
52
|
+
// could not be determined.
|
|
53
|
+
//
|
|
54
|
+
// null is NOT folded into 0. "I could not look" and "there is nothing there"
|
|
55
|
+
// must not render as the same sentence (the strand-watch.js rule): an
|
|
56
|
+
// undetermined branch is reported as undetermined, so a network failure cannot
|
|
57
|
+
// quietly certify a branch as clean.
|
|
58
|
+
function leftoversForTask(taskId, { branches, aheadCount }) {
|
|
59
|
+
const out = [];
|
|
60
|
+
for (const branch of branchesForTask(taskId, branches)) {
|
|
61
|
+
let raw;
|
|
62
|
+
try { raw = aheadCount(branch); } catch (_) { raw = null; }
|
|
63
|
+
const n = Number(raw);
|
|
64
|
+
// Anything that is not a finite number is UNDETERMINED, not zero. The first
|
|
65
|
+
// cut of this tested only for null/undefined, so a NaN — what
|
|
66
|
+
// `Number(git rev-list output)` yields when the command prints nothing —
|
|
67
|
+
// fell through to the "> 0" test, failed it, and was dropped as clean. That
|
|
68
|
+
// is the exact failure this module's header forbids: a branch nobody could
|
|
69
|
+
// read certified as a branch with nothing on it.
|
|
70
|
+
if (raw === null || raw === undefined || !Number.isFinite(n)) {
|
|
71
|
+
out.push({ branch, ahead: null, undetermined: true });
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
if (n > 0) out.push({ branch, ahead: n, undetermined: false });
|
|
75
|
+
}
|
|
76
|
+
return out;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// The message a builder sees. Returns null when there is nothing to say, so the
|
|
80
|
+
// caller can stay silent on the clean path rather than printing a reassurance
|
|
81
|
+
// nobody reads (and which would train people to skip the line that matters).
|
|
82
|
+
function leftoverNotice(taskId, leftovers) {
|
|
83
|
+
if (!leftovers || !leftovers.length) return null;
|
|
84
|
+
const lines = [`\n⚠ Task #${taskId} is retired, but its work is still on the remote:`];
|
|
85
|
+
for (const l of leftovers) {
|
|
86
|
+
lines.push(l.undetermined
|
|
87
|
+
? ` - ${l.branch} — could not tell whether it carries unmerged commits`
|
|
88
|
+
: ` - ${l.branch} — ${l.ahead} commit(s) not on main`);
|
|
89
|
+
}
|
|
90
|
+
lines.push(
|
|
91
|
+
'',
|
|
92
|
+
'An abandoned task with a live branch reads as live work, and anything filed',
|
|
93
|
+
'off its review sits against code that does not exist on main. Decide which:',
|
|
94
|
+
'',
|
|
95
|
+
` git push origin --delete ${leftovers[0].branch} # the work is cut — drop it`,
|
|
96
|
+
` node scripts/gds/api.js POST /api/gds/tasks/${taskId}/promote # it is not cut — revive the task`,
|
|
97
|
+
'',
|
|
98
|
+
'Close or draft the branch\'s PR either way — the remote is not this tool\'s to change.',
|
|
99
|
+
);
|
|
100
|
+
return lines.join('\n');
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
module.exports = { branchesForTask, leftoversForTask, leftoverNotice };
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.809",
|
|
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.809",
|
|
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.809",
|
|
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/abandon.js
CHANGED
|
@@ -21,6 +21,38 @@ const { cliClient, requireSession, arg } = require('./cli-lib');
|
|
|
21
21
|
// task 1003153 — the live-status set, shared with the dead-edge detector so
|
|
22
22
|
// "who did this cut strand" means the same thing in both places.
|
|
23
23
|
const { LIVE_STATUSES } = require('../../modules/lifecycle/dead-deps.js');
|
|
24
|
+
// task 1003779 — the other thing a cut leaves behind: the code itself.
|
|
25
|
+
const { leftoversForTask, leftoverNotice } = require('../../modules/lifecycle/abandon-leftovers.js');
|
|
26
|
+
const { execFileSync } = require('node:child_process');
|
|
27
|
+
|
|
28
|
+
// Best-effort git, always. Every call here is advisory decoration on an abandon
|
|
29
|
+
// that has ALREADY landed, so a missing git, a detached network or a shallow
|
|
30
|
+
// clone must return "I don't know" — never throw, and never abort the command.
|
|
31
|
+
function git(args, ms = 20000) {
|
|
32
|
+
return execFileSync('git', args, { encoding: 'utf8', timeout: ms, stdio: ['ignore', 'pipe', 'ignore'] });
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// The remote branches this task owns. ONE network round trip, scoped to the two
|
|
36
|
+
// name shapes worktree.js produces — listing all 800-odd heads to filter locally
|
|
37
|
+
// would be the expensive way to learn the same thing.
|
|
38
|
+
function remoteBranchesFor(taskId) {
|
|
39
|
+
try {
|
|
40
|
+
const out = git(['ls-remote', '--heads', 'origin', `refs/heads/task-${taskId}`, `refs/heads/task-${taskId}-*`]);
|
|
41
|
+
return out.split('\n').map((l) => l.split('\t')[1] || '').filter(Boolean)
|
|
42
|
+
.map((r) => r.replace(/^refs\/heads\//, ''));
|
|
43
|
+
} catch (_) { return null; } // null = could not look
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// How many commits the branch carries that main does not. Returns null when it
|
|
47
|
+
// cannot be determined, which leftoversForTask reports as undetermined rather
|
|
48
|
+
// than folding into "clean" — see that module's header.
|
|
49
|
+
function commitsAheadOfMain(branch) {
|
|
50
|
+
try {
|
|
51
|
+
git(['fetch', 'origin', '--quiet', branch, 'main']);
|
|
52
|
+
const n = Number(String(git(['rev-list', '--count', 'FETCH_HEAD', '--not', 'origin/main'])).trim());
|
|
53
|
+
return Number.isFinite(n) ? n : null;
|
|
54
|
+
} catch (_) { return null; }
|
|
55
|
+
}
|
|
24
56
|
|
|
25
57
|
|
|
26
58
|
(async () => {
|
|
@@ -79,6 +111,22 @@ const { LIVE_STATUSES } = require('../../modules/lifecycle/dead-deps.js');
|
|
|
79
111
|
console.log(' Sweep for any you miss: node scripts/gds/audit-deps.js --stranded');
|
|
80
112
|
}
|
|
81
113
|
} catch (_) { /* the abandon already landed — the report is advisory */ }
|
|
114
|
+
|
|
115
|
+
// task 1003779 — REPORT the code this cut orphaned. Abandoning retires the ROW;
|
|
116
|
+
// the branch and its PR stay exactly where they were, reading as live work.
|
|
117
|
+
// Task 1002464 was cut carrying two commits never merged to main, and the
|
|
118
|
+
// follow-up filed off its review (task 1003626) then sat at 'ready' asking for
|
|
119
|
+
// changes to a function that exists nowhere on main — 'sequence.js --check'
|
|
120
|
+
// called it clear to run. Nothing had said the branch was dead, so nothing could.
|
|
121
|
+
// Same posture as the stranded-dependents report above: best-effort, and it
|
|
122
|
+
// never fails an abandon that already landed.
|
|
123
|
+
try {
|
|
124
|
+
const branches = remoteBranchesFor(taskId);
|
|
125
|
+
if (branches && branches.length) {
|
|
126
|
+
const notice = leftoverNotice(taskId, leftoversForTask(taskId, { branches, aheadCount: commitsAheadOfMain }));
|
|
127
|
+
if (notice) console.log(notice);
|
|
128
|
+
}
|
|
129
|
+
} catch (_) { /* advisory only */ }
|
|
82
130
|
})().catch((err) => {
|
|
83
131
|
console.error('fatal:', err);
|
|
84
132
|
process.exit(1);
|
package/src/module-api.js
CHANGED
|
@@ -71,7 +71,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
|
|
|
71
71
|
// there. scripts/gds/bump-version.js still rewrites the literal below; it appends
|
|
72
72
|
// the entry to that file. Look for a version's history there, not here.
|
|
73
73
|
// ---------------------------------------------------------------------------
|
|
74
|
-
const CORE_VERSION = '1.19.
|
|
74
|
+
const CORE_VERSION = '1.19.809'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
75
75
|
|
|
76
76
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
77
77
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// tests/abandon_leftovers.mjs — what a retired task leaves on the remote
|
|
2
|
+
// (task 1003779). Pure + git-free: the ancestry oracle is injected, so these run
|
|
3
|
+
// with no repository, no network and no fixtures.
|
|
4
|
+
import assert from 'node:assert/strict';
|
|
5
|
+
import { test } from 'node:test';
|
|
6
|
+
import { createRequire } from 'node:module';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import { fileURLToPath } from 'node:url';
|
|
9
|
+
|
|
10
|
+
const require = createRequire(import.meta.url);
|
|
11
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
12
|
+
const { branchesForTask, leftoversForTask, leftoverNotice } =
|
|
13
|
+
require(path.join(ROOT, 'modules', 'lifecycle', 'abandon-leftovers.js'));
|
|
14
|
+
|
|
15
|
+
test('both branch shapes worktree.js produces belong to the task', () => {
|
|
16
|
+
const branches = ['task-1002464', 'task-1002503-backfill-recovery', 'main'];
|
|
17
|
+
assert.deepEqual(branchesForTask(1002464, branches), ['task-1002464']);
|
|
18
|
+
assert.deepEqual(branchesForTask(1002503, branches), ['task-1002503-backfill-recovery']);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test('THE MISREPORT THIS MUST NOT MAKE: a longer id is not a prefix match', () => {
|
|
22
|
+
// task-10024501 is ANOTHER task's branch. Matching it would tell a builder to
|
|
23
|
+
// delete someone else's unmerged work as their own leftovers — worse than
|
|
24
|
+
// reporting nothing at all, which is why the pattern is anchored at both ends.
|
|
25
|
+
assert.deepEqual(branchesForTask(1002450, ['task-10024501', 'task-1002450x', 'xtask-1002450']), []);
|
|
26
|
+
assert.deepEqual(branchesForTask(1002450, ['task-1002450', 'task-10024501']), ['task-1002450']);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test('the same branch given twice is reported once', () => {
|
|
30
|
+
// Callers may pass `git branch -r` output (origin/task-N) and `ls-remote`
|
|
31
|
+
// output (task-N) together; the notice would otherwise say "delete it" twice.
|
|
32
|
+
assert.deepEqual(branchesForTask(1002464, ['task-1002464', 'origin/task-1002464']), ['task-1002464']);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test('a non-numeric or missing id matches nothing rather than building a loose regex', () => {
|
|
36
|
+
for (const id of ['', null, undefined, 'x', '10*', '.*']) {
|
|
37
|
+
assert.deepEqual(branchesForTask(id, ['task-1002464', 'task-x']), [], `id ${JSON.stringify(id)}`);
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('a branch already merged into main is not a leftover — the silent path', () => {
|
|
42
|
+
// The load-bearing negative. Every shipped task keeps its branch, so if a
|
|
43
|
+
// merged branch counted, the notice would fire on nearly every abandon and
|
|
44
|
+
// become weather (the lesson task 1003770 recorded about the drift advisory).
|
|
45
|
+
assert.deepEqual(leftoversForTask(1002464, { branches: ['task-1002464'], aheadCount: () => 0 }), []);
|
|
46
|
+
assert.equal(leftoverNotice(1002464, []), null, 'nothing to say means say nothing');
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test('unmerged commits are reported with their count', () => {
|
|
50
|
+
assert.deepEqual(
|
|
51
|
+
leftoversForTask(1002464, { branches: ['task-1002464'], aheadCount: () => 2 }),
|
|
52
|
+
[{ branch: 'task-1002464', ahead: 2, undetermined: false }],
|
|
53
|
+
);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('"could not look" is never folded into "nothing there"', () => {
|
|
57
|
+
// A network failure must not silently certify a branch as clean — the
|
|
58
|
+
// strand-watch.js rule. null and a throw both surface as undetermined.
|
|
59
|
+
for (const oracle of [() => null, () => undefined, () => { throw new Error('offline'); }, () => NaN]) {
|
|
60
|
+
const r = leftoversForTask(1002464, { branches: ['task-1002464'], aheadCount: oracle });
|
|
61
|
+
assert.equal(r.length, 1);
|
|
62
|
+
assert.equal(r[0].undetermined, true);
|
|
63
|
+
assert.equal(r[0].ahead, null);
|
|
64
|
+
}
|
|
65
|
+
assert.match(leftoverNotice(1002464, leftoversForTask(1002464,
|
|
66
|
+
{ branches: ['task-1002464'], aheadCount: () => null })), /could not tell/);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('the notice names the task, the branch, and both ways out', () => {
|
|
70
|
+
const notice = leftoverNotice(1002464, leftoversForTask(1002464,
|
|
71
|
+
{ branches: ['task-1002464'], aheadCount: () => 2 }));
|
|
72
|
+
assert.match(notice, /#1002464/);
|
|
73
|
+
assert.match(notice, /task-1002464 — 2 commit\(s\) not on main/);
|
|
74
|
+
// Both dispositions, because which one applies is a human call: the work is
|
|
75
|
+
// cut (drop the branch) or it is not (revive the task).
|
|
76
|
+
assert.match(notice, /git push origin --delete task-1002464/);
|
|
77
|
+
assert.match(notice, /tasks\/1002464\/promote/);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test('a mixed set reports only the branches that actually carry work', () => {
|
|
81
|
+
const branches = ['task-1002464', 'task-1002464-followup'];
|
|
82
|
+
const r = leftoversForTask(1002464, {
|
|
83
|
+
branches,
|
|
84
|
+
aheadCount: (b) => (b === 'task-1002464' ? 3 : 0),
|
|
85
|
+
});
|
|
86
|
+
assert.deepEqual(r, [{ branch: 'task-1002464', ahead: 3, undetermined: false }]);
|
|
87
|
+
});
|