@bongos/core 1.19.805 → 1.19.807
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 +24 -19
- package/docs/copy-inventory.md +47 -34
- package/docs/copy-registry.json +200 -72
- package/docs/module-api-changelog.md +4 -0
- package/docs/recipes/upgrading-the-core.md +39 -0
- package/modules/hall-ui/public/goals-page.js +1 -1
- package/modules/hall-ui/public/goals-render.js +144 -22
- package/modules/hall-ui/public/goals.css +16 -1
- 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/goal_invite_consent.mjs +307 -0
- package/tests/routine_schedule.mjs +61 -1
|
@@ -883,7 +883,7 @@
|
|
|
883
883
|
// this client gate deliberately doesn't re-derive the protected-module list
|
|
884
884
|
// (that roster lives server-side only, module-scope-map.js) — a non-Archon
|
|
885
885
|
// owner/manager sees the control on a protected-scope goal and gets a clear
|
|
886
|
-
// toast if the server 403s (see
|
|
886
|
+
// toast if the server 403s (see sendInvitation's error handling below).
|
|
887
887
|
const isOwner = sameId(g.created_by, selfId());
|
|
888
888
|
const canManage = g.status === 'open' && (isArchon() || isOwner || iAmLead);
|
|
889
889
|
renderActions(g, { joined, canManage });
|
|
@@ -1122,6 +1122,49 @@
|
|
|
1122
1122
|
}
|
|
1123
1123
|
return state.directory;
|
|
1124
1124
|
}
|
|
1125
|
+
// WHO IS STILL INVITABLE. Two exclusions, both of which exist to stop the page
|
|
1126
|
+
// offering a name that can only fail:
|
|
1127
|
+
// - members, which POST /invitations 409s as `already_a_member`;
|
|
1128
|
+
// - anyone holding a PENDING row, which it 409s as `invite_pending`.
|
|
1129
|
+
// The second is the one that matters, because a pending invitation is INVISIBLE
|
|
1130
|
+
// from the member list — it is not a membership — so without this the user picks
|
|
1131
|
+
// a perfectly ordinary-looking name and is handed an error they had no way to
|
|
1132
|
+
// predict. `state.pendingRequests` carries BOTH kinds (invites the goal sent and
|
|
1133
|
+
// join-requests it received) and the server refuses on either, so neither is
|
|
1134
|
+
// filtered out here.
|
|
1135
|
+
function invitableBuilders(members) {
|
|
1136
|
+
const excluded = new Set((members || []).map((m) => String(m.builder_id)));
|
|
1137
|
+
for (const r of state.pendingRequests || []) excluded.add(String(r.builder_id));
|
|
1138
|
+
return (state.directory || []).filter((b) => !excluded.has(String(b.id)));
|
|
1139
|
+
}
|
|
1140
|
+
function builderOptionsHtml(list, selectedId) {
|
|
1141
|
+
return list.map((b) => {
|
|
1142
|
+
const id = String(b.id);
|
|
1143
|
+
const sel = selectedId && id === String(selectedId) ? ' selected' : '';
|
|
1144
|
+
return `<option value="${escapeHtml(id)}"${sel}>${escapeHtml(b.display_name || ('@' + b.github_login))}</option>`;
|
|
1145
|
+
}).join('');
|
|
1146
|
+
}
|
|
1147
|
+
// THE INVITE BOX SENDS AN INVITATION; IT DOES NOT ADD ANYONE (task 1002886).
|
|
1148
|
+
//
|
|
1149
|
+
// This control used to POST /goals/:id/members — the immediate force-add — and
|
|
1150
|
+
// then toast 'Invited.', which was simply untrue: the person was already in the
|
|
1151
|
+
// goal and had never been asked. Meanwhile the consented flow ADR 0112 is built
|
|
1152
|
+
// around (POST /goals/:id/invitations -> a pending row -> the invitee accepts)
|
|
1153
|
+
// had its receiving half fully wired in goal-inbox.js and NO sender anywhere in
|
|
1154
|
+
// the hall, so nothing in the product ever created an invitation.
|
|
1155
|
+
//
|
|
1156
|
+
// Now: the primary control invites, and the force-add survives as its own
|
|
1157
|
+
// explicitly-labelled affordance (owner ruling, 2026-09-16) so nobody loses a
|
|
1158
|
+
// capability they had — seeding a goal with a team who already agreed offline is
|
|
1159
|
+
// a real thing people do. The rule the owner set is that BOTH paths say what
|
|
1160
|
+
// they actually do; relocating the lie would have been worse than leaving it.
|
|
1161
|
+
//
|
|
1162
|
+
// NO MEMBERSHIP-KIND PICKER ON THE INVITE HALF, and that is not an oversight:
|
|
1163
|
+
// `goal_membership_requests` has no membership_kind column, so an accepted
|
|
1164
|
+
// invitation lands through addGoalMember's default 'member'. A picker here would
|
|
1165
|
+
// be a choice silently dropped on the floor — the same class of defect as the
|
|
1166
|
+
// toast it replaces. The form says so instead, and the picker stays on the
|
|
1167
|
+
// add-directly path, where it IS honoured.
|
|
1125
1168
|
function renderInviteBox(goalId, canInvite, members) {
|
|
1126
1169
|
const box = $('#goal-invite');
|
|
1127
1170
|
if (!box) return;
|
|
@@ -1129,6 +1172,7 @@
|
|
|
1129
1172
|
box.hidden = true;
|
|
1130
1173
|
box.innerHTML = '';
|
|
1131
1174
|
state.inviteOpen = false;
|
|
1175
|
+
state.addDirectOpen = false;
|
|
1132
1176
|
return;
|
|
1133
1177
|
}
|
|
1134
1178
|
box.hidden = false;
|
|
@@ -1141,42 +1185,111 @@
|
|
|
1141
1185
|
});
|
|
1142
1186
|
return;
|
|
1143
1187
|
}
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1188
|
+
// This function rebuilds the box from scratch on every call, and it is called
|
|
1189
|
+
// again when the pending-request list lands (see loadGoalMembership). Carry the
|
|
1190
|
+
// half-filled form across that redraw, or a note someone is part-way through
|
|
1191
|
+
// typing vanishes under them.
|
|
1192
|
+
const keep = {
|
|
1193
|
+
builder: $('#goal-invite-builder')?.value || '',
|
|
1194
|
+
note: $('#goal-invite-note')?.value || '',
|
|
1195
|
+
direct: $('#goal-direct-builder')?.value || '',
|
|
1196
|
+
kind: $('#goal-direct-kind')?.value || 'member',
|
|
1197
|
+
};
|
|
1198
|
+
const invitable = invitableBuilders(members);
|
|
1199
|
+
const none = !invitable.length;
|
|
1200
|
+
const emptyLabel = 'Nobody left to invite';
|
|
1149
1201
|
box.innerHTML = `
|
|
1150
1202
|
<form class="goal-invite__form" id="goal-invite-form">
|
|
1151
|
-
<select id="goal-invite-builder" required ${
|
|
1152
|
-
${
|
|
1153
|
-
:
|
|
1154
|
-
</select>
|
|
1155
|
-
<select id="goal-invite-kind">
|
|
1156
|
-
<option value="member">Member</option>
|
|
1157
|
-
<option value="lead">Manager</option>
|
|
1203
|
+
<select id="goal-invite-builder" required ${none ? 'disabled' : ''}>
|
|
1204
|
+
${none ? `<option value="">${emptyLabel}</option>`
|
|
1205
|
+
: `<option value="" disabled${keep.builder ? '' : ' selected'}>Choose a builder…</option>${builderOptionsHtml(invitable, keep.builder)}`}
|
|
1158
1206
|
</select>
|
|
1159
|
-
<
|
|
1207
|
+
<input id="goal-invite-note" type="text" maxlength="500" placeholder="Note (optional) — why you're asking" value="${escapeHtml(keep.note)}" ${none ? 'disabled' : ''}>
|
|
1208
|
+
<button class="landing__btn" type="submit" ${none ? 'disabled' : ''}>Send invitation</button>
|
|
1160
1209
|
<button class="btn-ghost" id="goal-invite-cancel" type="button">Cancel</button>
|
|
1161
|
-
</form
|
|
1210
|
+
</form>
|
|
1211
|
+
<p class="goal-invite__hint">They join as a member once they accept — you can change that afterwards. Nobody is added until they do.</p>
|
|
1212
|
+
${state.addDirectOpen ? `
|
|
1213
|
+
<form class="goal-invite__form goal-invite__direct" id="goal-direct-form">
|
|
1214
|
+
<select id="goal-direct-builder" required ${none ? 'disabled' : ''}>
|
|
1215
|
+
${none ? `<option value="">${emptyLabel}</option>`
|
|
1216
|
+
: `<option value="" disabled${keep.direct ? '' : ' selected'}>Choose a builder…</option>${builderOptionsHtml(invitable, keep.direct)}`}
|
|
1217
|
+
</select>
|
|
1218
|
+
<select id="goal-direct-kind" ${none ? 'disabled' : ''}>
|
|
1219
|
+
<option value="member"${keep.kind === 'member' ? ' selected' : ''}>Member</option>
|
|
1220
|
+
<option value="lead"${keep.kind === 'lead' ? ' selected' : ''}>Manager</option>
|
|
1221
|
+
</select>
|
|
1222
|
+
<button class="btn-ghost" type="submit" ${none ? 'disabled' : ''}>Add without asking</button>
|
|
1223
|
+
<button class="btn-ghost" id="goal-direct-cancel" type="button">Cancel</button>
|
|
1224
|
+
</form>
|
|
1225
|
+
<p class="goal-invite__hint">They are added straight away and never asked. Use this only for someone who has already agreed.</p>`
|
|
1226
|
+
: '<button class="goal-invite__more" id="goal-direct-open" type="button">Or add someone without asking…</button>'}`;
|
|
1162
1227
|
$('#goal-invite-cancel')?.addEventListener('click', () => {
|
|
1163
1228
|
state.inviteOpen = false;
|
|
1229
|
+
state.addDirectOpen = false;
|
|
1164
1230
|
renderInviteBox(goalId, canInvite, members);
|
|
1165
1231
|
});
|
|
1166
1232
|
$('#goal-invite-form')?.addEventListener('submit', (ev) => {
|
|
1167
1233
|
ev.preventDefault();
|
|
1168
1234
|
const builderId = $('#goal-invite-builder')?.value;
|
|
1169
|
-
const
|
|
1170
|
-
if (builderId)
|
|
1235
|
+
const note = $('#goal-invite-note')?.value || '';
|
|
1236
|
+
if (builderId) sendInvitation(goalId, builderId, note);
|
|
1237
|
+
});
|
|
1238
|
+
$('#goal-direct-open')?.addEventListener('click', () => {
|
|
1239
|
+
state.addDirectOpen = true;
|
|
1240
|
+
renderInviteBox(goalId, canInvite, members);
|
|
1241
|
+
});
|
|
1242
|
+
$('#goal-direct-cancel')?.addEventListener('click', () => {
|
|
1243
|
+
state.addDirectOpen = false;
|
|
1244
|
+
renderInviteBox(goalId, canInvite, members);
|
|
1245
|
+
});
|
|
1246
|
+
$('#goal-direct-form')?.addEventListener('submit', (ev) => {
|
|
1247
|
+
ev.preventDefault();
|
|
1248
|
+
const builderId = $('#goal-direct-builder')?.value;
|
|
1249
|
+
const membershipKind = $('#goal-direct-kind')?.value || 'member';
|
|
1250
|
+
if (builderId) addMemberDirectly(goalId, builderId, membershipKind);
|
|
1171
1251
|
});
|
|
1172
1252
|
}
|
|
1173
|
-
|
|
1253
|
+
// The ADR 0112 sender. 201 means a PENDING row exists — not a membership — so
|
|
1254
|
+
// the toast may not claim the person is in the goal.
|
|
1255
|
+
async function sendInvitation(goalId, builderId, note) {
|
|
1256
|
+
try {
|
|
1257
|
+
const body = { builder_id: Number(builderId) };
|
|
1258
|
+
const trimmed = String(note || '').trim();
|
|
1259
|
+
if (trimmed) body.note = trimmed;
|
|
1260
|
+
await postJSON(`${API}/goals/${encodeURIComponent(goalId)}/invitations`, body);
|
|
1261
|
+
toast('Invitation sent — they join once they accept.');
|
|
1262
|
+
state.inviteOpen = false;
|
|
1263
|
+
state.addDirectOpen = false;
|
|
1264
|
+
await loadDetail(goalId, { force: true });
|
|
1265
|
+
renderDetail(goalId);
|
|
1266
|
+
renderList();
|
|
1267
|
+
} catch (err) {
|
|
1268
|
+
const code = err?.data?.error;
|
|
1269
|
+
// `invite_pending` and `already_a_member` are both filtered out of the
|
|
1270
|
+
// dropdown above, so reaching either means the goal changed under this form
|
|
1271
|
+
// — say what happened rather than a generic failure, because the name really
|
|
1272
|
+
// was offered a moment ago.
|
|
1273
|
+
if (code === 'invite_pending') toast('They already have an invitation or a join-request pending on this goal.');
|
|
1274
|
+
else if (code === 'already_a_member') toast('They joined this goal already.');
|
|
1275
|
+
else if (code === 'builder_not_found') toast('That builder no longer exists.');
|
|
1276
|
+
else if (code === 'archon_required_for_protected_scope') toast('This goal is scoped to a protected module — only an Archon may bring someone in.');
|
|
1277
|
+
else if (code === 'metic_required_for_protected_scope') toast('A protected-scope goal requires a Metic+ member.');
|
|
1278
|
+
else if (code === 'not_owner_or_manager') toast('Only this goal\'s owner or a manager (or an Archon) may invite.');
|
|
1279
|
+
else if (code === 'goal_not_open') toast('This goal is not open.');
|
|
1280
|
+
else toast('Could not send that invitation just now.');
|
|
1281
|
+
}
|
|
1282
|
+
}
|
|
1283
|
+
// The force-add, kept on purpose and labelled for what it is. Same endpoint the
|
|
1284
|
+
// '+ Invite' button used to call behind the word 'Invited.'
|
|
1285
|
+
async function addMemberDirectly(goalId, builderId, membershipKind) {
|
|
1174
1286
|
try {
|
|
1175
1287
|
// `membership_kind`, not `role` — ADR 0174 reserves that word for the three
|
|
1176
1288
|
// crafts (task 1003013).
|
|
1177
1289
|
await postJSON(`${API}/goals/${encodeURIComponent(goalId)}/members`, { builder_id: Number(builderId), membership_kind: membershipKind });
|
|
1178
|
-
toast('
|
|
1290
|
+
toast('Added — they are in the goal now, without being asked.');
|
|
1179
1291
|
state.inviteOpen = false;
|
|
1292
|
+
state.addDirectOpen = false;
|
|
1180
1293
|
await loadDetail(goalId, { force: true });
|
|
1181
1294
|
renderDetail(goalId);
|
|
1182
1295
|
renderList();
|
|
@@ -1184,9 +1297,9 @@
|
|
|
1184
1297
|
const code = err?.data?.error;
|
|
1185
1298
|
if (code === 'archon_required_for_protected_scope') toast('This goal is scoped to a protected module — only an Archon may add members.');
|
|
1186
1299
|
else if (code === 'metic_required_for_protected_scope') toast('A protected-scope goal requires a Metic+ member.');
|
|
1187
|
-
else if (code === 'not_owner_or_manager') toast('Only this goal\'s owner or a manager (or an Archon) may
|
|
1300
|
+
else if (code === 'not_owner_or_manager') toast('Only this goal\'s owner or a manager (or an Archon) may add members.');
|
|
1188
1301
|
else if (code === 'goal_not_open') toast('This goal is not open.');
|
|
1189
|
-
else toast('Could not
|
|
1302
|
+
else toast('Could not add that builder just now.');
|
|
1190
1303
|
}
|
|
1191
1304
|
}
|
|
1192
1305
|
async function joinLeave(goalId, action) {
|
|
@@ -1324,7 +1437,16 @@
|
|
|
1324
1437
|
if (!sameId(state.current, goalId)) return;
|
|
1325
1438
|
renderPendingRequests();
|
|
1326
1439
|
const d = currentDetail();
|
|
1327
|
-
if (d)
|
|
1440
|
+
if (d) {
|
|
1441
|
+
renderActions(d.goal, { joined: (d.members || []).some((m) => sameId(m.builder_id, selfId())), canManage });
|
|
1442
|
+
// The invite dropdown's exclusion set reads state.pendingRequests, which
|
|
1443
|
+
// lands HERE — after renderDetail has already drawn the box. Without this
|
|
1444
|
+
// redraw a form opened before this resolved keeps offering a builder whose
|
|
1445
|
+
// invitation is already pending, which is the exact invisible 409 the
|
|
1446
|
+
// filter exists to prevent. renderInviteBox carries the half-filled form
|
|
1447
|
+
// across the redraw.
|
|
1448
|
+
if (state.inviteOpen) renderInviteBox(goalId, canManage, d.members || []);
|
|
1449
|
+
}
|
|
1328
1450
|
}
|
|
1329
1451
|
async function loadDetail(goalId, { force = false } = {}) {
|
|
1330
1452
|
if (!force && state.details.has(String(goalId))) return state.details.get(String(goalId));
|
|
@@ -200,7 +200,22 @@
|
|
|
200
200
|
collapsed behind "+ Invite" (disclosure, closed by default — task 2171). */
|
|
201
201
|
.goal-invite { margin: 4px 0 10px; }
|
|
202
202
|
.goal-invite__form { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
|
|
203
|
-
.goal-invite__form select#goal-invite-builder
|
|
203
|
+
.goal-invite__form select#goal-invite-builder,
|
|
204
|
+
.goal-invite__form select#goal-direct-builder { flex: 1; min-width: 180px; }
|
|
205
|
+
.goal-invite__form input#goal-invite-note { flex: 1; min-width: 180px; }
|
|
206
|
+
|
|
207
|
+
/* task 1002886 — BOTH admission paths now say what they do, so both carry a
|
|
208
|
+
line of their own: the invite half promises nobody is added until they
|
|
209
|
+
accept, the force-add half admits nobody is asked. Muted, because this is
|
|
210
|
+
the consequence of the button beside it, not a warning. */
|
|
211
|
+
.goal-invite__hint { margin: 4px 0 8px; font-size: 12.5px; color: var(--ink-faint); max-width: var(--measure); }
|
|
212
|
+
/* The force-add is deliberately QUIETER than the invite it sits under — same
|
|
213
|
+
authority, but it is the path that skips consent, so it does not get equal
|
|
214
|
+
visual weight. It stays one click away, never hidden. */
|
|
215
|
+
.goal-invite__more { background: none; border: 0; padding: 0; margin: 0 0 8px; cursor: pointer;
|
|
216
|
+
font: inherit; font-size: 12.5px; color: var(--ink-soft); text-decoration: underline; }
|
|
217
|
+
.goal-invite__more:hover { color: var(--ink); }
|
|
218
|
+
.goal-invite__direct { padding-top: 8px; border-top: var(--line) solid var(--rule-soft); }
|
|
204
219
|
|
|
205
220
|
/* task 1953 — private goals: the lock badge (meta line) + the goal's own
|
|
206
221
|
pending invite/join-request queue (manager view, next to the invite box). */
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.807",
|
|
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.807",
|
|
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.807",
|
|
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.807'; // 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');
|