@bongos/core 1.19.806 → 1.19.808
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 +17 -17
- package/docs/copy-inventory.md +58 -38
- package/docs/copy-registry.json +226 -46
- package/docs/module-api-changelog.md +4 -0
- package/docs/recipes/upgrading-the-core.md +39 -0
- package/modules/hall-ui/public/record.css +23 -0
- package/modules/hall-ui/public/task.js +224 -7
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/routine-schedule.js +57 -5
- package/src/module-api.js +1 -1
- package/tests/hall_record_world.mjs +183 -1
- package/tests/routine_schedule.mjs +61 -1
|
@@ -114,6 +114,45 @@ Each subscribed instance: read its installed core → ask the registry (`npm vie
|
|
|
114
114
|
|
|
115
115
|
**Triple-gated, so nothing moves by accident:** the `core-update-subscription` routine is `requiresAutonomy: true` + **default OFF** in [`config/scheduled-routines.json`](../../config/scheduled-routines.json), the roster ships empty, and every upgrade is health-gated. Arm it by enabling the routine's timer on the control plane once autonomy is on.
|
|
116
116
|
|
|
117
|
+
#### How often it sweeps — the lag knob (task 1003674)
|
|
118
|
+
|
|
119
|
+
**This timer's period IS the delay between a builder's merge and the live site
|
|
120
|
+
serving it.** Everything upstream is already fast: CI publishes to npm within
|
|
121
|
+
~2 minutes of a merge. The pull-deploy timer is fast too, but it watches the
|
|
122
|
+
*instance repo*, not npm — it logs `already at origin/main — nothing to deploy`
|
|
123
|
+
and only acts once a pin-bump commit exists. **This sweep is what creates that
|
|
124
|
+
commit**, so nothing reaches the site between its runs. Left at `OnCalendar=daily`
|
|
125
|
+
the worst case is ~24 hours, which is what made shipped work look unshipped and
|
|
126
|
+
pushed the owner into manual go-lives.
|
|
127
|
+
|
|
128
|
+
Install it at the declared cadence (`15min` in `config/scheduled-routines.json`):
|
|
129
|
+
|
|
130
|
+
```ini
|
|
131
|
+
# /etc/systemd/system/<instance>-core-update-subscription.timer
|
|
132
|
+
[Timer]
|
|
133
|
+
OnCalendar=*:0/15
|
|
134
|
+
RandomizedDelaySec=120
|
|
135
|
+
Persistent=true
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
```bash
|
|
139
|
+
sudo systemctl daemon-reload && sudo systemctl restart <instance>-core-update-subscription.timer
|
|
140
|
+
systemctl list-timers '*core-update-subscription*' --no-pager # confirm the next elapse is minutes away
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
**Do not go below ~5 minutes.** An upgrading run takes ~2 minutes; at a tighter
|
|
144
|
+
spacing runs overlap. A run with nothing to take costs one `npm view` and exits,
|
|
145
|
+
so 15 minutes is cheap — the cost scales with releases, not with checks.
|
|
146
|
+
|
|
147
|
+
This does **not** weaken the pinned-core model ([ADR 0108](../adr/0108-instance-composition-configurable-root.md)):
|
|
148
|
+
the pin still exists, a bad release can still be frozen or rolled back, and every
|
|
149
|
+
upgrade stays health-gated with auto-rollback (task 2149). A faster sweep only
|
|
150
|
+
removes the wait.
|
|
151
|
+
|
|
152
|
+
**Verify:** merge something, wait for the next elapse, and confirm
|
|
153
|
+
`curl -s https://<instance>/version` reports the new `coreVersion` with nobody
|
|
154
|
+
running a go-live.
|
|
155
|
+
|
|
117
156
|
**Not covered:** multi-host / SSH orchestration across a real fleet (the deferred fleet-control-plane epic, task [#1948](https://example.com/builders#/task/1948)); and *publishing* new patches, which stays a gated owner step (ADR 0134) — a follow-up task decides whether merges auto-publish.
|
|
118
157
|
|
|
119
158
|
## Gotchas
|
|
@@ -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 () => {
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.808",
|
|
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.808",
|
|
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.808",
|
|
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",
|
|
@@ -52,11 +52,49 @@ const WORKFLOWS_DIR = path.join(ROOT, '.github', 'workflows');
|
|
|
52
52
|
|
|
53
53
|
const { parseRoutineFrontmatter } = require('./run-routine.js');
|
|
54
54
|
|
|
55
|
-
// The cadences a routine may declare. The value is the period a cron
|
|
56
|
-
// must resolve to for the two to agree. Add a name here and
|
|
57
|
-
// must be able to produce it, or the pair can never match.
|
|
55
|
+
// The NAMED cadences a routine may declare. The value is the period a cron
|
|
56
|
+
// expression must resolve to for the two to agree. Add a name here and
|
|
57
|
+
// cronCadence() below must be able to produce it, or the pair can never match.
|
|
58
58
|
const CADENCES = new Set(['hourly', 'daily', 'weekly', 'monthly']);
|
|
59
59
|
|
|
60
|
+
// Sub-hourly cadences are open-ended rather than enumerated: '15min', '5min',
|
|
61
|
+
// '30min'. Task 1003674 found the vocabulary topped out at `hourly`, so a
|
|
62
|
+
// routine an operator wants to fire every 15 minutes could not SAY so — the
|
|
63
|
+
// step cron that realizes it classified as unclassifiable and hard-failed the
|
|
64
|
+
// check, which left "declare nothing and stay dormant" as the only green
|
|
65
|
+
// option. That is the exact failure mode this file exists to refuse.
|
|
66
|
+
//
|
|
67
|
+
// N MUST DIVIDE 60. A cron step restarts each hour, so only a divisor of 60
|
|
68
|
+
// yields a genuinely uniform period: `*/15` fires at :00 :15 :30 :45 (every 15
|
|
69
|
+
// minutes, always), but `*/7` fires at :00 …:56 and then waits 4 — a "7min"
|
|
70
|
+
// cadence that is not one. Refusing the non-divisors keeps the declared period
|
|
71
|
+
// and the realized period the same claim.
|
|
72
|
+
const SUBHOURLY_RE = /^([1-9]\d*)min$/;
|
|
73
|
+
|
|
74
|
+
// The one definition of "is N a real sub-hourly period" — both the cadence NAME
|
|
75
|
+
// ('15min') and the cron STEP (*/15) are validated through here, so the two can
|
|
76
|
+
// never drift apart and disagree about the same number.
|
|
77
|
+
function isUniformDivisor(n) {
|
|
78
|
+
return Number.isInteger(n) && n > 0 && n < 60 && 60 % n === 0;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function subhourlyMinutes(name) {
|
|
82
|
+
const m = SUBHOURLY_RE.exec(String(name == null ? '' : name));
|
|
83
|
+
if (!m) return null;
|
|
84
|
+
const n = Number(m[1]);
|
|
85
|
+
return isUniformDivisor(n) ? n : null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// The validity predicate the check asks. Kept separate from CADENCES so the
|
|
89
|
+
// named set stays a plain enumerable list for the error message.
|
|
90
|
+
function isCadence(name) {
|
|
91
|
+
return CADENCES.has(name) || subhourlyMinutes(name) !== null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// The minute-field step spelling, hoisted so it is compiled once rather than per
|
|
95
|
+
// classified cron: `*/15` and the equivalent `0/15`.
|
|
96
|
+
const STEP_RE = /^(?:\*|0)\/([1-9]\d*)$/;
|
|
97
|
+
|
|
60
98
|
// Pure: classify a 5-field cron expression into the cadence it actually realizes.
|
|
61
99
|
// Returns null for anything this vocabulary cannot describe (step values, lists,
|
|
62
100
|
// ranges) — a cadence can then never "match" it, which is the safe direction: an
|
|
@@ -67,12 +105,25 @@ const CADENCES = new Set(['hourly', 'daily', 'weekly', 'monthly']);
|
|
|
67
105
|
// 17 6 * * * → daily (a specific hour, every day)
|
|
68
106
|
// 17 6 3 * * → monthly (a specific day-of-month)
|
|
69
107
|
// 17 * * * * → hourly (a specific minute, every hour)
|
|
108
|
+
// */15 * * * * → 15min (a uniform sub-hourly step)
|
|
70
109
|
function cronCadence(expr) {
|
|
71
110
|
const f = String(expr == null ? '' : expr).trim().split(/\s+/);
|
|
72
111
|
if (f.length !== 5) return null;
|
|
73
112
|
const [min, hour, dom, mon, dow] = f;
|
|
74
113
|
const fixed = (v) => /^\d+$/.test(v);
|
|
75
114
|
const any = (v) => v === '*';
|
|
115
|
+
|
|
116
|
+
// Sub-hourly: a step in the minute field with every coarser field wide open.
|
|
117
|
+
// (STEP_RE is module-scope — see above.)
|
|
118
|
+
// Anything narrower (a step confined to one hour, or to weekdays) is NOT the
|
|
119
|
+
// period it looks like, so it stays unclassifiable.
|
|
120
|
+
const step = STEP_RE.exec(min);
|
|
121
|
+
if (step) {
|
|
122
|
+
if (!(any(hour) && any(dom) && any(mon) && any(dow))) return null;
|
|
123
|
+
const n = Number(step[1]);
|
|
124
|
+
return isUniformDivisor(n) ? `${n}min` : null;
|
|
125
|
+
}
|
|
126
|
+
|
|
76
127
|
if (!fixed(min)) return null; // every cadence below pins the minute
|
|
77
128
|
if (!any(mon)) return null; // a specific month is a yearly shape
|
|
78
129
|
if (any(hour)) return any(dom) && any(dow) ? 'hourly' : null;
|
|
@@ -145,8 +196,8 @@ function evaluate({ withCadence, withoutCadence }, workflows) {
|
|
|
145
196
|
const wf = workflows || [];
|
|
146
197
|
|
|
147
198
|
for (const r of withCadence) {
|
|
148
|
-
if (!
|
|
149
|
-
violations.push(`routine '${r.name}': unknown cadence "${r.cadence}" (want ${[...CADENCES].join('|')})`);
|
|
199
|
+
if (!isCadence(r.cadence)) {
|
|
200
|
+
violations.push(`routine '${r.name}': unknown cadence "${r.cadence}" (want ${[...CADENCES].join('|')}, or <N>min where N divides 60)`);
|
|
150
201
|
continue;
|
|
151
202
|
}
|
|
152
203
|
const firing = wf.filter((w) => invokesRoutine(w.text, r.name, r.steps));
|
|
@@ -228,4 +279,5 @@ if (require.main === module) main();
|
|
|
228
279
|
module.exports = {
|
|
229
280
|
checkRoutineCadencesScheduled, evaluate, cronCadence, workflowCrons,
|
|
230
281
|
invokesRoutine, readRoutines, readWorkflows, CADENCES,
|
|
282
|
+
isCadence, subhourlyMinutes, isUniformDivisor,
|
|
231
283
|
};
|
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.808'; // 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');
|