@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.
@@ -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 inviteMember's error handling below).
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
- const memberIds = new Set((members || []).map((m) => String(m.builder_id)));
1145
- const invitable = (state.directory || []).filter((b) => !memberIds.has(String(b.id)));
1146
- const options = invitable
1147
- .map((b) => `<option value="${escapeHtml(String(b.id))}">${escapeHtml(b.display_name || ('@' + b.github_login))}</option>`)
1148
- .join('');
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 ${invitable.length ? '' : 'disabled'}>
1152
- ${invitable.length ? `<option value="" disabled selected>Choose a builder…</option>${options}`
1153
- : '<option value="">Everyone is already a member</option>'}
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
- <button class="landing__btn" type="submit" ${invitable.length ? '' : 'disabled'}>Invite</button>
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 membershipKind = $('#goal-invite-kind')?.value || 'member';
1170
- if (builderId) inviteMember(goalId, builderId, membershipKind);
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
- async function inviteMember(goalId, builderId, membershipKind) {
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('Invited.');
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 invite.');
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 invite that builder just now.');
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) renderActions(d.goal, { joined: (d.members || []).some((m) => sameId(m.builder_id, selfId())), canManage });
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 { flex: 1; min-width: 180px; }
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.805",
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.805",
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.805",
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 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.805'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
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');