@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
|
@@ -274,8 +274,15 @@ const MD_SRC = read('md-reader.js');
|
|
|
274
274
|
// (default) echoes the task back at 200 with goal_id applied from the request
|
|
275
275
|
// body (so a reload's GET reflects the move); { status, data } refuses it with
|
|
276
276
|
// that shape instead, for the refusal-surfacing tests.
|
|
277
|
-
|
|
277
|
+
// task 1002858 — `write` refuses the shaping routes the way `patch` refuses the
|
|
278
|
+
// goal move: { status, data }, applied to POST /dependencies, DELETE
|
|
279
|
+
// /dependencies/:id and POST /abandon. `writes` records method + path + body for
|
|
280
|
+
// all of them, because what these tests are actually about is the REQUEST the
|
|
281
|
+
// page issues — `requests` carries paths only and is left alone so the older
|
|
282
|
+
// assertions on it keep meaning what they meant.
|
|
283
|
+
function renderTask(task, { lastClaim = null, viewer = null, goal = null, goalsList = null, patch = null, write = null, status = 200, search = '', referrer = '', withReader = true, grade, sessionLogs, claims } = {}) {
|
|
278
284
|
const requests = [];
|
|
285
|
+
const writes = [];
|
|
279
286
|
const toasts = [];
|
|
280
287
|
const els = new Map();
|
|
281
288
|
const makeEl = () => ({
|
|
@@ -302,6 +309,15 @@ function renderTask(task, { lastClaim = null, viewer = null, goal = null, goalsL
|
|
|
302
309
|
const client = {
|
|
303
310
|
request: async (method, p, opts) => {
|
|
304
311
|
requests.push(p);
|
|
312
|
+
if (method !== 'GET') writes.push({ method, path: p, body: (opts && opts.body) || null });
|
|
313
|
+
// task 1002858 — the shaping routes. Checked before the generic /tasks/\d+
|
|
314
|
+
// read below, which ignores method and would otherwise swallow them.
|
|
315
|
+
if (/\/tasks\/\d+\/dependencies/.test(p) || /\/tasks\/\d+\/abandon$/.test(p)) {
|
|
316
|
+
if (write && write.status && write.status !== 200) {
|
|
317
|
+
return { status: write.status, ok: false, data: write.data || null };
|
|
318
|
+
}
|
|
319
|
+
return { status: 200, ok: true, data: { ok: true } };
|
|
320
|
+
}
|
|
305
321
|
// PATCH must be checked before the generic /tasks/\d+ read below — both
|
|
306
322
|
// match the same path shape, and the read branch ignores method.
|
|
307
323
|
if (method === 'PATCH' && /\/tasks\/\d+$/.test(p)) {
|
|
@@ -356,6 +372,7 @@ function renderTask(task, { lastClaim = null, viewer = null, goal = null, goalsL
|
|
|
356
372
|
sub: () => documentObj.getElementById('task-sub').textContent,
|
|
357
373
|
el: (id) => documentObj.getElementById(id),
|
|
358
374
|
requests,
|
|
375
|
+
writes,
|
|
359
376
|
toasts,
|
|
360
377
|
};
|
|
361
378
|
}
|
|
@@ -821,6 +838,171 @@ test('a successful move-to-goal PATCH re-renders the goal line against the new g
|
|
|
821
838
|
'the goal line reflects the new goal after the PATCH triggers a reload');
|
|
822
839
|
});
|
|
823
840
|
|
|
841
|
+
// ---- 11b. the task-SHAPING verbs (task 1002858) -----------------------------
|
|
842
|
+
//
|
|
843
|
+
// priority, dependency add/remove and abandon all had routes and no hall control,
|
|
844
|
+
// so a goal lead shaping their goal's work dropped to the CLI to retire a
|
|
845
|
+
// duplicate, re-rank a task or fix a dependency chain. Same posture as every
|
|
846
|
+
// other write on this page (ADR 0016): the server owns the gate, this only
|
|
847
|
+
// decides what to draw and prints the refusal it gets back.
|
|
848
|
+
//
|
|
849
|
+
// WHAT THESE TESTS ARE FOR is the REQUEST each control issues — the method, the
|
|
850
|
+
// path and the body — because a control that renders correctly and sends the
|
|
851
|
+
// wrong thing looks identical from the markup. That is the defect class the
|
|
852
|
+
// invite button in task 1002886 carried for months.
|
|
853
|
+
|
|
854
|
+
test('the shaping controls render for Metic/Archon and not for Xenos/Thetes', async () => {
|
|
855
|
+
// BASE_TASK is shipped, and a shipped task deliberately loses the retire
|
|
856
|
+
// control (see the guard test below) — so this one asks about a LIVE task.
|
|
857
|
+
const OPEN_TASK = { ...BASE_TASK, status: 'ready' };
|
|
858
|
+
for (const rank of ['metic', 'archon']) {
|
|
859
|
+
const r = renderTask(OPEN_TASK, { viewer: { rank } });
|
|
860
|
+
await r.boot(); await settle();
|
|
861
|
+
assert.match(r.html(), /id="priority-select"/, `${rank} sees the priority control`);
|
|
862
|
+
assert.match(r.html(), /id="dep-add-input"/, `${rank} sees the add-dependency control`);
|
|
863
|
+
assert.match(r.html(), /id="abandon-open-btn"/, `${rank} sees the retire control`);
|
|
864
|
+
}
|
|
865
|
+
for (const rank of ['xenos', 'thetes']) {
|
|
866
|
+
const r = renderTask(OPEN_TASK, { viewer: { rank } });
|
|
867
|
+
await r.boot(); await settle();
|
|
868
|
+
for (const id of ['priority-select', 'dep-add-input', 'abandon-open-btn']) {
|
|
869
|
+
assert.doesNotMatch(r.html(), new RegExp(`id="${id}"`), `${rank} must not see ${id}`);
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
});
|
|
873
|
+
|
|
874
|
+
test('setting a priority PATCHes the priority field, and unset is sent as null', async () => {
|
|
875
|
+
const r = renderTask({ ...BASE_TASK, priority: 5 }, { viewer: { rank: 'metic' } });
|
|
876
|
+
await r.boot(); await settle();
|
|
877
|
+
r.el('priority-select').value = '2';
|
|
878
|
+
await r.el('priority-btn')._handlers.click();
|
|
879
|
+
await settle();
|
|
880
|
+
const patchCall = r.writes.find((w) => w.method === 'PATCH');
|
|
881
|
+
assert.ok(patchCall, 'a PATCH was issued');
|
|
882
|
+
assert.equal(patchCall.body.priority, 2);
|
|
883
|
+
|
|
884
|
+
// '' is the UNSET option and must travel as null: "no priority" is a real state
|
|
885
|
+
// the optimizer sorts last (NULLS LAST), not a synonym for P5. Sending nothing
|
|
886
|
+
// would 400 as no_supported_fields.
|
|
887
|
+
const r2 = renderTask({ ...BASE_TASK, priority: 3 }, { viewer: { rank: 'metic' } });
|
|
888
|
+
await r2.boot(); await settle();
|
|
889
|
+
r2.el('priority-select').value = '';
|
|
890
|
+
await r2.el('priority-btn')._handlers.click();
|
|
891
|
+
await settle();
|
|
892
|
+
const clear = r2.writes.find((w) => w.method === 'PATCH');
|
|
893
|
+
assert.ok(clear && Object.prototype.hasOwnProperty.call(clear.body, 'priority'), 'priority is present');
|
|
894
|
+
assert.equal(clear.body.priority, null, 'and it is null, not "" and not 5');
|
|
895
|
+
});
|
|
896
|
+
|
|
897
|
+
test('re-setting the priority it already has sends nothing', async () => {
|
|
898
|
+
const r = renderTask({ ...BASE_TASK, priority: 3 }, { viewer: { rank: 'metic' } });
|
|
899
|
+
await r.boot(); await settle();
|
|
900
|
+
r.el('priority-select').value = '3';
|
|
901
|
+
await r.el('priority-btn')._handlers.click();
|
|
902
|
+
await settle();
|
|
903
|
+
assert.equal(r.writes.filter((w) => w.method === 'PATCH').length, 0, 'no write for a no-op');
|
|
904
|
+
assert.match(r.toasts.join(' '), /already P3/);
|
|
905
|
+
});
|
|
906
|
+
|
|
907
|
+
test('adding a dependency POSTs the id, and refuses a self-dependency before the network', async () => {
|
|
908
|
+
const r = renderTask(BASE_TASK, { viewer: { rank: 'metic' } });
|
|
909
|
+
await r.boot(); await settle();
|
|
910
|
+
r.el('dep-add-input').value = '4242';
|
|
911
|
+
await r.el('dep-add-btn')._handlers.click();
|
|
912
|
+
await settle();
|
|
913
|
+
const post = r.writes.find((w) => w.method === 'POST' && /\/dependencies$/.test(w.path));
|
|
914
|
+
assert.ok(post, 'a POST to /dependencies was issued');
|
|
915
|
+
assert.equal(post.body.depends_on_task_id, 4242);
|
|
916
|
+
|
|
917
|
+
const r2 = renderTask(BASE_TASK, { viewer: { rank: 'metic' } });
|
|
918
|
+
await r2.boot(); await settle();
|
|
919
|
+
r2.el('dep-add-input').value = String(BASE_TASK.id);
|
|
920
|
+
await r2.el('dep-add-btn')._handlers.click();
|
|
921
|
+
await settle();
|
|
922
|
+
assert.equal(r2.writes.length, 0, 'a self-dependency is caught before the request');
|
|
923
|
+
assert.match(r2.toasts.join(' '), /cannot wait for itself/i);
|
|
924
|
+
});
|
|
925
|
+
|
|
926
|
+
test('the × cuts an edge with DELETE, and only ever from "Blocked by"', async () => {
|
|
927
|
+
// The asymmetry is the point. DELETE /tasks/:id/dependencies/:depId removes the
|
|
928
|
+
// edge where THIS task waits on another — its own business. The "Blocks" list is
|
|
929
|
+
// the same edges from the far end: a × there would edit a DIFFERENT task's
|
|
930
|
+
// dependency set from a page not showing it.
|
|
931
|
+
const task = {
|
|
932
|
+
...BASE_TASK,
|
|
933
|
+
dependencies: [{ id: 900, title: 'upstream', status: 'ready' }],
|
|
934
|
+
dependents: [{ id: 901, title: 'downstream', status: 'ready' }],
|
|
935
|
+
};
|
|
936
|
+
const r = renderTask(task, { viewer: { rank: 'metic' } });
|
|
937
|
+
await r.boot(); await settle();
|
|
938
|
+
const html = r.html();
|
|
939
|
+
assert.match(html, /data-cut-dep="900"/, 'the blocked-by edge is cuttable');
|
|
940
|
+
assert.doesNotMatch(html, /data-cut-dep="901"/, 'the blocks edge is NOT cuttable from here');
|
|
941
|
+
|
|
942
|
+
const xenos = renderTask(task, { viewer: { rank: 'xenos' } });
|
|
943
|
+
await xenos.boot(); await settle();
|
|
944
|
+
assert.doesNotMatch(xenos.html(), /data-cut-dep=/, 'and it is Metic+ only');
|
|
945
|
+
});
|
|
946
|
+
|
|
947
|
+
test('retire requires a reason, then POSTs it to /abandon', async () => {
|
|
948
|
+
const r = renderTask({ ...BASE_TASK, status: 'ready' }, { viewer: { rank: 'metic' } });
|
|
949
|
+
await r.boot(); await settle();
|
|
950
|
+
await r.el('abandon-open-btn')._handlers.click();
|
|
951
|
+
await settle();
|
|
952
|
+
assert.match(r.html(), /id="abandon-reason"/, 'the disclosure opens onto a reason field');
|
|
953
|
+
|
|
954
|
+
// A REASON IS REQUIRED though the server accepts an empty one: the value of the
|
|
955
|
+
// audit_log row is the sentence in it.
|
|
956
|
+
r.el('abandon-reason').value = ' ';
|
|
957
|
+
await r.el('abandon-btn')._handlers.click();
|
|
958
|
+
await settle();
|
|
959
|
+
assert.equal(r.writes.length, 0, 'a blank reason never reaches the server');
|
|
960
|
+
assert.match(r.toasts.join(' '), /Say why/i);
|
|
961
|
+
|
|
962
|
+
r.el('abandon-reason').value = 'superseded by the consolidated task';
|
|
963
|
+
await r.el('abandon-btn')._handlers.click();
|
|
964
|
+
await settle();
|
|
965
|
+
const post = r.writes.find((w) => /\/abandon$/.test(w.path));
|
|
966
|
+
assert.ok(post, 'a POST to /abandon was issued');
|
|
967
|
+
assert.equal(post.method, 'POST');
|
|
968
|
+
assert.equal(post.body.reason, 'superseded by the consolidated task');
|
|
969
|
+
assert.match(r.toasts.join(' '), /Promote restores it/i,
|
|
970
|
+
'and the toast names the way back, so a misclick is visibly recoverable');
|
|
971
|
+
});
|
|
972
|
+
|
|
973
|
+
test('retire is replaced by a sentence where the server would only refuse it', async () => {
|
|
974
|
+
// Both of these are 409s the server raises anyway AND are knowable from this
|
|
975
|
+
// record. Offering a button that cannot work teaches the reader the page guesses.
|
|
976
|
+
const shipped = renderTask({ ...BASE_TASK, status: 'shipped' }, { viewer: { rank: 'metic' } });
|
|
977
|
+
await shipped.boot(); await settle();
|
|
978
|
+
assert.doesNotMatch(shipped.html(), /id="abandon-open-btn"/, 'no retire control on shipped');
|
|
979
|
+
assert.match(shipped.html(), /immutable history/i, 'it says why instead');
|
|
980
|
+
|
|
981
|
+
const held = renderTask({ ...BASE_TASK, status: 'active' }, {
|
|
982
|
+
viewer: { rank: 'metic' },
|
|
983
|
+
lastClaim: { github_login: 'someone', claimed_at: '2026-09-01T00:00:00Z', released_at: null },
|
|
984
|
+
});
|
|
985
|
+
await held.boot(); await settle();
|
|
986
|
+
assert.doesNotMatch(held.html(), /id="abandon-open-btn"/, 'no retire control while a claim is live');
|
|
987
|
+
assert.match(held.html(), /released/i, 'it names the step that unblocks it');
|
|
988
|
+
});
|
|
989
|
+
|
|
990
|
+
test("a shaping refusal surfaces the server's own sentence, verbatim", async () => {
|
|
991
|
+
const serverMsg = 'release the active claim before abandoning';
|
|
992
|
+
const r = renderTask({ ...BASE_TASK, status: 'ready' }, {
|
|
993
|
+
viewer: { rank: 'metic' },
|
|
994
|
+
write: { status: 409, data: { error: { code: 'task_has_active_claim', message: serverMsg } } },
|
|
995
|
+
});
|
|
996
|
+
await r.boot(); await settle();
|
|
997
|
+
await r.el('abandon-open-btn')._handlers.click();
|
|
998
|
+
await settle();
|
|
999
|
+
r.el('abandon-reason').value = 'no longer relevant';
|
|
1000
|
+
await r.el('abandon-btn')._handlers.click();
|
|
1001
|
+
await settle();
|
|
1002
|
+
assert.ok(r.toasts.some((m) => m.includes(serverMsg)),
|
|
1003
|
+
`the server's sentence is printed as-is — got ${JSON.stringify(r.toasts)}`);
|
|
1004
|
+
});
|
|
1005
|
+
|
|
824
1006
|
test("a move-to-goal refusal (403) surfaces the server's own sentence, verbatim", async () => {
|
|
825
1007
|
const task = { ...BASE_TASK, goal_id: 1000071 };
|
|
826
1008
|
const goalsList = [{ id: 1000071, title: 'Old goal', status: 'open' }, { id: 555, title: 'New goal', status: 'open' }];
|
|
@@ -50,13 +50,73 @@ test('cronCadence refuses what its vocabulary cannot describe, rather than guess
|
|
|
50
50
|
// Unclassifiable must mean "never matches a declared cadence" — the safe
|
|
51
51
|
// direction. A clever cron that silently counted as `weekly` would reopen
|
|
52
52
|
// exactly the drift this check exists to catch.
|
|
53
|
-
for (const expr of ['
|
|
53
|
+
for (const expr of ['0 6 * * 1-5', '0 6 * * 1,4', '0 0 1 1 *', '17 6 * *', '', 'nonsense']) {
|
|
54
54
|
assert.equal(rs.cronCadence(expr), null, `${expr} should not classify`);
|
|
55
55
|
}
|
|
56
56
|
// A day-of-month AND day-of-week together is an OR in cron — neither period.
|
|
57
57
|
assert.equal(rs.cronCadence('17 6 3 * 1'), null);
|
|
58
58
|
});
|
|
59
59
|
|
|
60
|
+
test('cronCadence classifies a uniform sub-hourly step (task 1003674)', () => {
|
|
61
|
+
// The vocabulary used to stop at `hourly`, so a routine meant to fire every
|
|
62
|
+
// 15 minutes had no way to declare it: the cron that realizes it read as
|
|
63
|
+
// unclassifiable and hard-failed, leaving "declare nothing, stay dormant" as
|
|
64
|
+
// the only green option — the state this check exists to refuse.
|
|
65
|
+
assert.equal(rs.cronCadence('*/15 * * * *'), '15min');
|
|
66
|
+
assert.equal(rs.cronCadence('*/5 * * * *'), '5min');
|
|
67
|
+
assert.equal(rs.cronCadence('*/30 * * * *'), '30min');
|
|
68
|
+
assert.equal(rs.cronCadence('0/15 * * * *'), '15min', 'the 0/N spelling is the same schedule');
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test('cronCadence refuses a step that is not a true period (task 1003674)', () => {
|
|
72
|
+
// A cron step restarts every hour, so only a divisor of 60 is uniform:
|
|
73
|
+
// */7 fires at :00…:56 then waits 4. Calling that "7min" would be the same
|
|
74
|
+
// silent drift between declared and realized period the check is here for.
|
|
75
|
+
for (const expr of ['*/7 * * * *', '*/8 * * * *', '*/45 * * * *', '*/60 * * * *', '*/0 * * * *']) {
|
|
76
|
+
assert.equal(rs.cronCadence(expr), null, `${expr} is not a uniform period`);
|
|
77
|
+
}
|
|
78
|
+
// A step narrowed to one hour or one weekday is not the sub-hourly period it
|
|
79
|
+
// resembles — it only fires inside that window.
|
|
80
|
+
assert.equal(rs.cronCadence('*/15 6 * * *'), null);
|
|
81
|
+
assert.equal(rs.cronCadence('*/15 * * * 1'), null);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test('isCadence accepts the named periods and the divisor-of-60 Nmin form (task 1003674)', () => {
|
|
85
|
+
for (const ok of ['hourly', 'daily', 'weekly', 'monthly', '15min', '5min', '30min', '1min']) {
|
|
86
|
+
assert.equal(rs.isCadence(ok), true, `${ok} should be declarable`);
|
|
87
|
+
}
|
|
88
|
+
for (const no of ['7min', '60min', '90min', 'min', '0min', 'fortnightly', '', null, undefined]) {
|
|
89
|
+
assert.equal(rs.isCadence(no), false, `${no} should not be declarable`);
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test('a routine declaring a sub-hourly cadence matches a step cron that fires it (task 1003674)', () => {
|
|
94
|
+
// End-to-end through evaluate(): declaring 15min is only green when a
|
|
95
|
+
// committed scheduler actually fires it at 15 minutes.
|
|
96
|
+
const wf = (cron) => [{
|
|
97
|
+
file: 'sweep.yml',
|
|
98
|
+
text: `on:\n schedule:\n - cron: '${cron}'\njobs:\n go:\n steps:\n - run: node scripts/gds/run-routine.js sweepy\n`,
|
|
99
|
+
}];
|
|
100
|
+
const declared = routines([{ name: 'sweepy', cadence: '15min', steps: [] }]);
|
|
101
|
+
assert.deepEqual(rs.evaluate(declared, wf('*/15 * * * *')).violations, []);
|
|
102
|
+
|
|
103
|
+
// Wrong period, and the check names both sides rather than shrugging.
|
|
104
|
+
const drifted = rs.evaluate(declared, wf('17 6 * * *')).violations;
|
|
105
|
+
assert.equal(drifted.length, 1);
|
|
106
|
+
assert.match(drifted[0], /declares cadence: 15min/);
|
|
107
|
+
assert.match(drifted[0], /-> daily|→ daily/);
|
|
108
|
+
|
|
109
|
+
// A non-divisor step cannot satisfy it either — it classifies as nothing.
|
|
110
|
+
assert.match(rs.evaluate(declared, wf('*/7 * * * *')).violations[0], /unclassifiable/);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test('an undeclarable cadence is refused by name (task 1003674)', () => {
|
|
114
|
+
const v = rs.evaluate(routines([{ name: 'sweepy', cadence: '7min', steps: [] }]), []).violations;
|
|
115
|
+
assert.equal(v.length, 1);
|
|
116
|
+
assert.match(v[0], /unknown cadence "7min"/);
|
|
117
|
+
assert.match(v[0], /N divides 60/);
|
|
118
|
+
});
|
|
119
|
+
|
|
60
120
|
test('workflowCrons reads every cron a workflow declares, quoted or bare, ignoring trailing comments', () => {
|
|
61
121
|
const y = [" schedule:", " - cron: '17 6 * * 1'", ' - cron: "0 3 * * *"', ' - cron: 5 * * * * # bare + comment'].join('\n');
|
|
62
122
|
assert.deepEqual(rs.workflowCrons(y), ['17 6 * * 1', '0 3 * * *', '5 * * * *']);
|