@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.
@@ -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 + NAVIGATION ONLY (task 751 scope) — no claim/release/ship action lives
11
- // here. The "Copy prompt" button is purely client-side (clipboard write, no
12
- // server call), same posture as the Work Board's copy action. Claim/release
13
- // from the browser is the dependent follow-up task.
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
- function depList(kindWord, items) {
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.806",
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.806",
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.806",
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 expression
56
- // must resolve to for the two to agree. Add a name here and cronCadence() below
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 (!CADENCES.has(r.cadence)) {
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.806'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
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');