@bongos/core 1.19.524 → 1.19.526
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 +40 -20
- package/docs/adr/0241-the-artist-gate-is-a-per-project-deploy-gate-that-reads-a-state.md +185 -0
- package/docs/adr/0242-a-cascade-is-a-declaration-table-on-the-event-that-already-exists.md +107 -0
- package/docs/adr/README.md +2 -0
- package/docs/copy-inventory.md +8 -9
- package/docs/copy-registry.json +9 -18
- package/docs/module-api-changelog.md +4 -0
- package/modules/hall-ui/public/collab-lib.js +37 -13
- package/modules/hall-ui/public/collab.js +31 -8
- package/modules/lifecycle/cascade.js +238 -0
- package/modules/lifecycle/routes/lifecycle.js +25 -0
- package/modules/lifecycle/tests/cascade.mjs +197 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/module-api.js +1 -1
- package/tests/collab_page.mjs +60 -3
- package/tests/hall_collab_world.mjs +22 -3
package/docs/copy-registry.json
CHANGED
|
@@ -3,9 +3,9 @@
|
|
|
3
3
|
"generator": "scripts/gds/copy-inventory.js",
|
|
4
4
|
"goal": 1000074,
|
|
5
5
|
"counts": {
|
|
6
|
-
"strings":
|
|
7
|
-
"unique_ids":
|
|
8
|
-
"certain":
|
|
6
|
+
"strings": 1645,
|
|
7
|
+
"unique_ids": 1427,
|
|
8
|
+
"certain": 1645,
|
|
9
9
|
"heuristic": 0,
|
|
10
10
|
"duplicate_ids": 154
|
|
11
11
|
},
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
"builders-hall": {
|
|
14
14
|
"at": "modules/hall-ui/public",
|
|
15
15
|
"files": 73,
|
|
16
|
-
"strings":
|
|
16
|
+
"strings": 1187
|
|
17
17
|
},
|
|
18
18
|
"status-dashboard": {
|
|
19
19
|
"at": "modules/status-ui/public",
|
|
@@ -2072,7 +2072,7 @@
|
|
|
2072
2072
|
"surface": "builders-hall",
|
|
2073
2073
|
"text": "A {…} — you are a {…}. Mingle pairs across crafts.",
|
|
2074
2074
|
"file": "modules/hall-ui/public/collab.js",
|
|
2075
|
-
"line":
|
|
2075
|
+
"line": 196,
|
|
2076
2076
|
"origin": "js-markup",
|
|
2077
2077
|
"confidence": "certain"
|
|
2078
2078
|
},
|
|
@@ -3467,7 +3467,7 @@
|
|
|
3467
3467
|
"surface": "builders-hall",
|
|
3468
3468
|
"text": "Could not load requests.",
|
|
3469
3469
|
"file": "modules/hall-ui/public/collab.js",
|
|
3470
|
-
"line":
|
|
3470
|
+
"line": 274,
|
|
3471
3471
|
"origin": "js-text-assign",
|
|
3472
3472
|
"confidence": "certain"
|
|
3473
3473
|
},
|
|
@@ -6383,7 +6383,7 @@
|
|
|
6383
6383
|
"surface": "builders-hall",
|
|
6384
6384
|
"text": "Not now",
|
|
6385
6385
|
"file": "modules/hall-ui/public/collab.js",
|
|
6386
|
-
"line":
|
|
6386
|
+
"line": 196,
|
|
6387
6387
|
"origin": "js-markup",
|
|
6388
6388
|
"confidence": "certain"
|
|
6389
6389
|
},
|
|
@@ -7760,7 +7760,7 @@
|
|
|
7760
7760
|
"surface": "builders-hall",
|
|
7761
7761
|
"text": "Say yes",
|
|
7762
7762
|
"file": "modules/hall-ui/public/collab.js",
|
|
7763
|
-
"line":
|
|
7763
|
+
"line": 196,
|
|
7764
7764
|
"origin": "js-markup",
|
|
7765
7765
|
"confidence": "certain"
|
|
7766
7766
|
},
|
|
@@ -8246,7 +8246,7 @@
|
|
|
8246
8246
|
"surface": "builders-hall",
|
|
8247
8247
|
"text": "Someone helped",
|
|
8248
8248
|
"file": "modules/hall-ui/public/collab.js",
|
|
8249
|
-
"line":
|
|
8249
|
+
"line": 113,
|
|
8250
8250
|
"origin": "js-markup",
|
|
8251
8251
|
"confidence": "certain"
|
|
8252
8252
|
},
|
|
@@ -10302,15 +10302,6 @@
|
|
|
10302
10302
|
"origin": "js-markup",
|
|
10303
10303
|
"confidence": "certain"
|
|
10304
10304
|
},
|
|
10305
|
-
{
|
|
10306
|
-
"id": "09db3fd4355b",
|
|
10307
|
-
"surface": "builders-hall",
|
|
10308
|
-
"text": "asked by {…}",
|
|
10309
|
-
"file": "modules/hall-ui/public/collab.js",
|
|
10310
|
-
"line": 69,
|
|
10311
|
-
"origin": "js-markup",
|
|
10312
|
-
"confidence": "certain"
|
|
10313
|
-
},
|
|
10314
10305
|
{
|
|
10315
10306
|
"id": "63bd14f73960",
|
|
10316
10307
|
"surface": "builders-hall",
|
|
@@ -1485,5 +1485,9 @@ is load-bearing: the script throws rather than guess if it is missing, and
|
|
|
1485
1485
|
landed since 1.19.522 with no explicit bump. run 33816191865. (task 1002620)
|
|
1486
1486
|
1.19.524 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1487
1487
|
landed since 1.19.523 with no explicit bump. run 33818285201. (task 1002620)
|
|
1488
|
+
1.19.525 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1489
|
+
landed since 1.19.524 with no explicit bump. run 33823218193. (task 1002620)
|
|
1490
|
+
1.19.526 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1491
|
+
landed since 1.19.525 with no explicit bump. run 33824700579. (task 1002620)
|
|
1488
1492
|
---------------------------------------------------------------------------
|
|
1489
1493
|
```
|
|
@@ -51,19 +51,42 @@
|
|
|
51
51
|
return (ta || 0) - (tb || 0);
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
-
//
|
|
55
|
-
//
|
|
56
|
-
//
|
|
57
|
-
//
|
|
58
|
-
//
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
54
|
+
// `addressedTo(req, viewerId)` WAS HERE and is gone (task 1003274). It named
|
|
55
|
+
// the addressee only when the VIEWER was the addressee, which is why the
|
|
56
|
+
// asker's own list — the most common case for that branch — rendered "asked of
|
|
57
|
+
// one person" and produced the owner's report. directionLine below supersedes
|
|
58
|
+
// it on both axes (it names the person AND says which way the ask points), and
|
|
59
|
+
// it had no other caller, so keeping it would have left a second, wronger
|
|
60
|
+
// answer to the same question sitting exported next to the right one.
|
|
61
|
+
|
|
62
|
+
// DIRECTION, in words (owner report, task 1003274). The report is the whole
|
|
63
|
+
// reason this exists: the owner read their own outgoing ask, addressed to Lars,
|
|
64
|
+
// sitting under "Your asks", and concluded it had been sent to the wrong person.
|
|
65
|
+
// Nothing was misrouted — the row simply never said WHO, and never said which
|
|
66
|
+
// way it pointed.
|
|
67
|
+
//
|
|
68
|
+
// DIRECTION IS THE LIST, NOT A COMPARISON. `dir` is 'out' for your own asks and
|
|
69
|
+
// 'in' for what was asked of you, because the two server reads already answer
|
|
70
|
+
// that question by construction (GET /help-requests/mine vs /for-me). Deriving
|
|
71
|
+
// it instead from `requested_by === viewerId` is what the old code did, and it
|
|
72
|
+
// is how a page with no viewer id in hand ends up rendering the anonymous
|
|
73
|
+
// fallback for the single most common case — the asker reading their own list.
|
|
74
|
+
//
|
|
75
|
+
// The tonal split the report asks to KEEP: a craft ask is a call anyone of that
|
|
76
|
+
// craft may take; a by-name ask is personal and says so.
|
|
77
|
+
function directionLine(req, dir) {
|
|
78
|
+
if (!req) return '';
|
|
79
|
+
var craft = req.needs_craft;
|
|
80
|
+
if (dir === 'out') {
|
|
81
|
+
if (req.needs_builder_id != null) {
|
|
82
|
+
var to = req.needs_builder_name || req.needs_builder_login;
|
|
83
|
+
return to ? 'You asked ' + to + ' by name' : 'You asked one person';
|
|
84
|
+
}
|
|
85
|
+
return craft ? 'You asked ' + craft + 's' : 'You asked';
|
|
64
86
|
}
|
|
65
|
-
|
|
66
|
-
return 'asked';
|
|
87
|
+
var who = askerOf(req) || 'Someone';
|
|
88
|
+
if (req.needs_builder_id != null) return who + ' asked you by name';
|
|
89
|
+
return craft ? who + ' asked ' + craft + 's' : who + ' asked';
|
|
67
90
|
}
|
|
68
91
|
|
|
69
92
|
// Who is ASKING, in words — the fact a helper needs to answer. The name the
|
|
@@ -107,7 +130,8 @@
|
|
|
107
130
|
|
|
108
131
|
window.OTBCollabLib = {
|
|
109
132
|
BANDS: BANDS, ageBand: ageBand, byAgeOldestFirst: byAgeOldestFirst,
|
|
110
|
-
|
|
133
|
+
askerOf: askerOf, contextOf: contextOf,
|
|
134
|
+
directionLine: directionLine,
|
|
111
135
|
titleOf: titleOf, groupOf: groupOf,
|
|
112
136
|
};
|
|
113
137
|
})();
|
|
@@ -59,14 +59,37 @@
|
|
|
59
59
|
return `<a class="fact-chip" href="${escapeHtml(ctx.href)}">${escapeHtml(ctx.kind)} #${escapeHtml(String(ctx.id))}</a>`;
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
-
//
|
|
63
|
-
|
|
64
|
-
|
|
62
|
+
// A name as a door to that builder's profile, or plain text when the server
|
|
63
|
+
// sent no id to link to.
|
|
64
|
+
function personLink(id, name) {
|
|
65
65
|
if (!name) return '';
|
|
66
|
-
|
|
67
|
-
? `<a href="/builders/profile?id=${escapeHtml(String(
|
|
66
|
+
return id != null
|
|
67
|
+
? `<a href="/builders/profile?id=${escapeHtml(String(id))}">${escapeHtml(name)}</a>`
|
|
68
68
|
: escapeHtml(name);
|
|
69
|
-
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// DIRECTION, on the row (owner report, task 1003274): "<name> asked you by
|
|
72
|
+
// name" incoming, "You asked <name> by name" outgoing — so an outgoing ask can
|
|
73
|
+
// never be mistaken for an incoming one, which is exactly the mistake the
|
|
74
|
+
// report describes. The sentence itself is L.directionLine (pure, tested); this
|
|
75
|
+
// only splices the profile link over the person's name inside it, so the words
|
|
76
|
+
// a test asserts and the words a builder reads cannot drift.
|
|
77
|
+
function directionFact(req, dir) {
|
|
78
|
+
const line = L.directionLine(req, dir);
|
|
79
|
+
if (!line) return '';
|
|
80
|
+
const name = dir === 'in' ? L.askerOf(req) : (req.needs_builder_name || req.needs_builder_login);
|
|
81
|
+
const id = dir === 'in' ? req.requested_by : req.needs_builder_id;
|
|
82
|
+
const link = personLink(id, name);
|
|
83
|
+
// The replacement is a FUNCTION, not a string. String.prototype.replace
|
|
84
|
+
// treats `$&`, `$$`, "$`" and `$'` specially in a string replacement, and the
|
|
85
|
+
// name here is builder-controlled (a display_name or login) — so a display
|
|
86
|
+
// name containing `$&` would duplicate or mangle the sentence around it. Not
|
|
87
|
+
// an injection (both halves are escaped either way), but a real way to
|
|
88
|
+
// corrupt someone else's row by choosing your own name.
|
|
89
|
+
const html = (name && link)
|
|
90
|
+
? escapeHtml(line).replace(escapeHtml(name), () => link)
|
|
91
|
+
: escapeHtml(line);
|
|
92
|
+
return `<span class="ov-fact">${html}</span>`;
|
|
70
93
|
}
|
|
71
94
|
|
|
72
95
|
// A row for the "asked of you" list. There is deliberately NO "claim this"
|
|
@@ -77,7 +100,7 @@
|
|
|
77
100
|
function forMeRow(req, nowMs) {
|
|
78
101
|
return `<li class="ov-row" data-id="${escapeHtml(String(req.id))}">
|
|
79
102
|
<div class="ov-row__main">${titleHtml(req)}
|
|
80
|
-
<div class="ov-row__facts">${
|
|
103
|
+
<div class="ov-row__facts">${directionFact(req, 'in')}${contextChip(req)}</div>
|
|
81
104
|
</div>
|
|
82
105
|
<div class="ov-row__right">${ageChip(req, nowMs)}${whenLine(req.created_at)}</div>
|
|
83
106
|
</li>`;
|
|
@@ -89,7 +112,7 @@
|
|
|
89
112
|
const id = escapeHtml(String(req.id));
|
|
90
113
|
return `<li class="ov-row" data-id="${id}">
|
|
91
114
|
<div class="ov-row__main">${titleHtml(req)}
|
|
92
|
-
<div class="ov-row__facts"
|
|
115
|
+
<div class="ov-row__facts">${directionFact(req, 'out')}${contextChip(req)}</div>
|
|
93
116
|
</div>
|
|
94
117
|
<div class="ov-row__right">${ageChip(req, nowMs)}
|
|
95
118
|
<div class="ov-row__actions">
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// modules/lifecycle/cascade.js — a ship in one role GENERATES work in another
|
|
4
|
+
// (task 1003549, goal 1000074's the artist's loop; the split R04 asked for).
|
|
5
|
+
//
|
|
6
|
+
// WHAT WAS MISSING, precisely — the gap is PUSH vs PULL. Two surfaces already
|
|
7
|
+
// let one role take work from another, and both are LISTINGS of rows somebody
|
|
8
|
+
// had already created: the spark queue (goal 1000062 / task 1002921) returns
|
|
9
|
+
// open sparks awaiting development, and the copy-desk flag queue (R02 / task
|
|
10
|
+
// 1003113) returns surfaces anyone flagged. A role could READ what another
|
|
11
|
+
// produced; nothing GENERATED work on a state transition. R04's requirement is
|
|
12
|
+
// the other direction — "every ship with copy or visuals generates one [review
|
|
13
|
+
// task]", automatic, never hand-filed — and there was no mechanism for it.
|
|
14
|
+
//
|
|
15
|
+
// WHAT THIS IS, AND WHAT IT DELIBERATELY IS NOT. It is a DECLARATION TABLE plus
|
|
16
|
+
// a runner, not a rules engine. A cascade is one frozen entry saying: when a
|
|
17
|
+
// shipped task looks like THIS, file THAT task, for THAT discipline. There is no
|
|
18
|
+
// condition language, no stored rules, no per-project configuration and no UI —
|
|
19
|
+
// every one of those is a thing to add when a second and third cascade exist and
|
|
20
|
+
// have shown what they have in common. Today `CASCADES` ships EMPTY: the
|
|
21
|
+
// mechanism, its idempotency and its non-blocking property are proved by the
|
|
22
|
+
// tests' own declarations, and R04 (task 1003115) writes the first production
|
|
23
|
+
// entry. A mechanism whose first real rule is written by the task that needs it
|
|
24
|
+
// is honest; one that ships a rule nobody asked for is a guess.
|
|
25
|
+
//
|
|
26
|
+
// THE FOUR PROPERTIES THAT MAKE IT SAFE, each of which the tests pin:
|
|
27
|
+
//
|
|
28
|
+
// 1. IT NEVER BLOCKS THE SHIP IT REACTS TO. The runner is driven by the
|
|
29
|
+
// `task.shipped` kernel event, which fires POST-COMMIT and whose emitters
|
|
30
|
+
// already wrap it in try/catch; on top of that `runCascadesForShip` catches
|
|
31
|
+
// everything and returns a report. A cascade that throws, or a database
|
|
32
|
+
// that is down, changes nothing about the ship — the work is landed, graded
|
|
33
|
+
// and paid before this code runs at all. This is not a nicety: ADR 0241
|
|
34
|
+
// settled that an artist's review may hold a DEPLOY and never a builder's
|
|
35
|
+
// ship, and a generator that could fail a ship would smuggle that gate back
|
|
36
|
+
// in through the back door.
|
|
37
|
+
//
|
|
38
|
+
// 2. IT IS IDEMPOTENT, using machinery that already exists. Each generated
|
|
39
|
+
// task carries `source: 'cascade'` and `source_ref: cascade-<key>-<id>`,
|
|
40
|
+
// and `createTask`'s (source, source_ref) idempotency read returns the
|
|
41
|
+
// original row instead of minting a second one. So a reconciler re-sweep, a
|
|
42
|
+
// re-ship, or both emitters firing for the same task are no-ops. The
|
|
43
|
+
// runner ALSO checks first, so the common case is a read rather than a
|
|
44
|
+
// write that gets rejected.
|
|
45
|
+
//
|
|
46
|
+
// 3. IT SAYS WHERE IT CAME FROM. Every generated task inherits the source
|
|
47
|
+
// task's `goal_id` and `version_id`, carries the originating task's id
|
|
48
|
+
// inside its `source_ref`, and opens its description with that id and the
|
|
49
|
+
// rule key. A task that appears on the board with no author has to be able
|
|
50
|
+
// to explain itself, or the next person deletes it. NOT `parent_task_id`:
|
|
51
|
+
// that column models a SUBTASK, and cascaded work is not a part of the ship
|
|
52
|
+
// that triggered it — it is separate work, for a different role, that
|
|
53
|
+
// outlives its trigger. The attribution belongs in the ref and the prose.
|
|
54
|
+
//
|
|
55
|
+
// 4. IT FILES AT `backlog`, NEVER `ready`. Generated work is a REQUEST, and
|
|
56
|
+
// it goes through the board's ordinary triage like a copy proposal (ADR
|
|
57
|
+
// 0233 §6) or any capture. Auto-filing claimable work would let a rule
|
|
58
|
+
// nobody re-read mint claims.
|
|
59
|
+
//
|
|
60
|
+
// PURE ABOVE `runCascadesForShip`: the declaration shape, the matching and the
|
|
61
|
+
// task spec are all synchronous functions over plain objects, so a cascade can
|
|
62
|
+
// be pinned without a database.
|
|
63
|
+
|
|
64
|
+
// ---------------------------------------------------------------------------
|
|
65
|
+
// The declaration
|
|
66
|
+
// ---------------------------------------------------------------------------
|
|
67
|
+
|
|
68
|
+
// A cascade rule:
|
|
69
|
+
// key string, stable + unique. It is half the source_ref, so RENAMING
|
|
70
|
+
// a key re-files every task the rule ever generated. Treat it as
|
|
71
|
+
// permanent.
|
|
72
|
+
// discipline the role the generated work is FOR ('artist', 'ideator',
|
|
73
|
+
// 'engineer'). This is the whole point of the mechanism: the
|
|
74
|
+
// discipline of the generated task is not the discipline of the
|
|
75
|
+
// ship that caused it.
|
|
76
|
+
// when(task) a predicate over the shipped task row. Must be pure and total —
|
|
77
|
+
// a throw is caught and treated as "no match", because a broken
|
|
78
|
+
// predicate must not stop the other rules.
|
|
79
|
+
// title(task) / body(task) the generated task's title and description.
|
|
80
|
+
// kind optional task kind; defaults to 'chore'.
|
|
81
|
+
//
|
|
82
|
+
// Production rules live here. It is empty by design — see the header.
|
|
83
|
+
const CASCADES = Object.freeze([]);
|
|
84
|
+
|
|
85
|
+
// The (source, source_ref) pair the generated task is keyed on. `source` is a
|
|
86
|
+
// constant so every cascade-generated row is findable as a class; the ref pins
|
|
87
|
+
// it to one rule and one originating ship.
|
|
88
|
+
const CASCADE_SOURCE = 'cascade';
|
|
89
|
+
|
|
90
|
+
function cascadeSourceRef(ruleKey, sourceTaskId) {
|
|
91
|
+
return `cascade-${ruleKey}-${sourceTaskId}`;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Validate a declaration at the moment it is used rather than trusting it. A
|
|
95
|
+
// malformed rule is a wiring bug that should be loud in a test, not a silent
|
|
96
|
+
// missing task in production six weeks later.
|
|
97
|
+
function assertRuleShape(rule) {
|
|
98
|
+
const problems = [];
|
|
99
|
+
if (!rule || typeof rule !== 'object') return ['rule is not an object'];
|
|
100
|
+
if (typeof rule.key !== 'string' || !rule.key) problems.push('key must be a non-empty string');
|
|
101
|
+
if (typeof rule.discipline !== 'string' || !rule.discipline) problems.push('discipline must be a non-empty string');
|
|
102
|
+
if (typeof rule.when !== 'function') problems.push('when must be a function');
|
|
103
|
+
if (typeof rule.title !== 'function') problems.push('title must be a function');
|
|
104
|
+
if (typeof rule.body !== 'function') problems.push('body must be a function');
|
|
105
|
+
return problems;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Every rule in a table, checked, with duplicate keys reported — two rules
|
|
109
|
+
// sharing a key would collide on source_ref and silently suppress one another.
|
|
110
|
+
function validateCascades(rules = CASCADES) {
|
|
111
|
+
const errors = [];
|
|
112
|
+
const seen = new Set();
|
|
113
|
+
rules.forEach((rule, i) => {
|
|
114
|
+
for (const p of assertRuleShape(rule)) errors.push(`cascade[${i}]: ${p}`);
|
|
115
|
+
const key = rule && rule.key;
|
|
116
|
+
if (typeof key === 'string' && key) {
|
|
117
|
+
if (seen.has(key)) errors.push(`cascade[${i}]: duplicate key "${key}" — keys are half the source_ref and must be unique`);
|
|
118
|
+
seen.add(key);
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
return errors;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ---------------------------------------------------------------------------
|
|
125
|
+
// Matching + the generated task spec
|
|
126
|
+
// ---------------------------------------------------------------------------
|
|
127
|
+
|
|
128
|
+
// The task rows a shipped task cascades into. Pure: no database, no clock, no
|
|
129
|
+
// side effect — hand it a task-shaped object and a rule table and it returns
|
|
130
|
+
// the specs that would be filed.
|
|
131
|
+
//
|
|
132
|
+
// A rule whose `when` THROWS is treated as no-match and reported in `errors`,
|
|
133
|
+
// never propagated: one broken predicate must not cost the other rules their
|
|
134
|
+
// tasks, and it certainly must not reach the ship.
|
|
135
|
+
function cascadesFor(task, rules = CASCADES) {
|
|
136
|
+
const matched = [];
|
|
137
|
+
const errors = [];
|
|
138
|
+
if (!task || task.id == null) return { matched, errors: ['no task'] };
|
|
139
|
+
for (const rule of rules) {
|
|
140
|
+
const shape = assertRuleShape(rule);
|
|
141
|
+
if (shape.length) { errors.push(`cascade "${rule && rule.key}": ${shape.join('; ')}`); continue; }
|
|
142
|
+
let hit = false;
|
|
143
|
+
try {
|
|
144
|
+
hit = rule.when(task) === true;
|
|
145
|
+
} catch (e) {
|
|
146
|
+
errors.push(`cascade "${rule.key}": when() threw (${e && e.message}) — treated as no match`);
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
if (!hit) continue;
|
|
150
|
+
try {
|
|
151
|
+
matched.push({ rule, spec: cascadeTaskSpec(rule, task) });
|
|
152
|
+
} catch (e) {
|
|
153
|
+
errors.push(`cascade "${rule.key}": title/body threw (${e && e.message})`);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return { matched, errors };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// The createTask payload for one matched rule. The attribution preamble is
|
|
160
|
+
// prepended here rather than left to each rule, so no rule can forget it.
|
|
161
|
+
function cascadeTaskSpec(rule, task) {
|
|
162
|
+
const body = String(rule.body(task) || '').trim();
|
|
163
|
+
const preamble =
|
|
164
|
+
`Filed automatically when task ${task.id} shipped — the "${rule.key}" cascade `
|
|
165
|
+
+ `(modules/lifecycle/cascade.js). Nobody typed this; it is a REQUEST, filed at backlog, `
|
|
166
|
+
+ `and it goes through the board's ordinary triage. If the rule that made it is wrong, fix `
|
|
167
|
+
+ `the rule rather than only this task.`;
|
|
168
|
+
return {
|
|
169
|
+
versionId: task.version_id,
|
|
170
|
+
goalId: task.goal_id != null ? Number(task.goal_id) : null,
|
|
171
|
+
title: String(rule.title(task) || '').trim(),
|
|
172
|
+
description: `${preamble}\n\n${body}`,
|
|
173
|
+
discipline: rule.discipline,
|
|
174
|
+
kind: rule.kind || 'chore',
|
|
175
|
+
status: 'backlog',
|
|
176
|
+
source: CASCADE_SOURCE,
|
|
177
|
+
sourceRef: cascadeSourceRef(rule.key, task.id),
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// ---------------------------------------------------------------------------
|
|
182
|
+
// The runner — the only part that touches a database
|
|
183
|
+
// ---------------------------------------------------------------------------
|
|
184
|
+
|
|
185
|
+
// React to one shipped task. Returns a report, NEVER throws and never rejects:
|
|
186
|
+
// property 1 in the header. `deps` is injected so the whole runner is testable
|
|
187
|
+
// with two stubs and no database.
|
|
188
|
+
//
|
|
189
|
+
// deps.getTask(id) -> the full task row (the event payload is
|
|
190
|
+
// the ship RETURNING row and carries no
|
|
191
|
+
// goal_id / version_id / discipline)
|
|
192
|
+
// deps.findTaskBySourceRef({...}) -> an existing row or null
|
|
193
|
+
// deps.createTask(spec) -> the created row
|
|
194
|
+
// deps.log -> optional { info, error }
|
|
195
|
+
async function runCascadesForShip(shipped, deps = {}) {
|
|
196
|
+
const report = { created: [], skipped: [], errors: [] };
|
|
197
|
+
const log = deps.log || null;
|
|
198
|
+
try {
|
|
199
|
+
const id = shipped && (shipped.id ?? shipped.task_id);
|
|
200
|
+
if (id == null) { report.errors.push('cascade: ship payload carried no task id'); return report; }
|
|
201
|
+
|
|
202
|
+
const task = deps.getTask ? await deps.getTask(id) : null;
|
|
203
|
+
if (!task) { report.errors.push(`cascade: task ${id} not readable after ship`); return report; }
|
|
204
|
+
|
|
205
|
+
const { matched, errors } = cascadesFor(task, deps.rules || CASCADES);
|
|
206
|
+
report.errors.push(...errors);
|
|
207
|
+
|
|
208
|
+
for (const { rule, spec } of matched) {
|
|
209
|
+
try {
|
|
210
|
+
const existing = deps.findTaskBySourceRef
|
|
211
|
+
? await deps.findTaskBySourceRef({ source: CASCADE_SOURCE, sourceRef: spec.sourceRef })
|
|
212
|
+
: null;
|
|
213
|
+
if (existing) { report.skipped.push({ key: rule.key, taskId: existing.id, reason: 'already filed' }); continue; }
|
|
214
|
+
const created = await deps.createTask(spec);
|
|
215
|
+
report.created.push({ key: rule.key, taskId: created && created.id, discipline: rule.discipline });
|
|
216
|
+
log?.info?.(`[cascade] task ${id} shipped → filed ${created && created.id} for ${rule.discipline} ("${rule.key}")`);
|
|
217
|
+
} catch (e) {
|
|
218
|
+
// One rule failing costs that rule its task and nothing else.
|
|
219
|
+
report.errors.push(`cascade "${rule.key}": ${e && e.message}`);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
} catch (e) {
|
|
223
|
+
report.errors.push(`cascade: ${e && e.message}`);
|
|
224
|
+
}
|
|
225
|
+
if (report.errors.length) log?.error?.(`[cascade] ${report.errors.length} problem(s): ${report.errors.join(' | ')}`);
|
|
226
|
+
return report;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
module.exports = {
|
|
230
|
+
CASCADES,
|
|
231
|
+
CASCADE_SOURCE,
|
|
232
|
+
cascadeSourceRef,
|
|
233
|
+
assertRuleShape,
|
|
234
|
+
validateCascades,
|
|
235
|
+
cascadesFor,
|
|
236
|
+
cascadeTaskSpec,
|
|
237
|
+
runCascadesForShip,
|
|
238
|
+
};
|
|
@@ -26,11 +26,36 @@
|
|
|
26
26
|
const express = require('express');
|
|
27
27
|
const api = require('../../../src/module-api');
|
|
28
28
|
const lifecycle = require('../lifecycle');
|
|
29
|
+
const db = require('../db');
|
|
30
|
+
const cascade = require('../cascade');
|
|
31
|
+
|
|
32
|
+
// task 1003549 — the cross-role cascade listener. `task.shipped` already exists
|
|
33
|
+
// as a kernel event (emitted post-commit by the publish reconciler and the ship
|
|
34
|
+
// routes, consumed today by discord's broadcast and sessions' deferred reward),
|
|
35
|
+
// so a ship generating work in ANOTHER role needs no new bus: it is one more
|
|
36
|
+
// listener on the event that already says a ship happened. Registered here for
|
|
37
|
+
// the same reason the port is — `mountModuleRoutes` is the one boot hook that
|
|
38
|
+
// runs in the live server AND whenever a test rebuilds the router.
|
|
39
|
+
//
|
|
40
|
+
// The handle is kept so a re-mount replaces the listener instead of stacking a
|
|
41
|
+
// second one (the discord `_shipListenerOff` precedent); a doubled listener
|
|
42
|
+
// would be harmless anyway, since filing is idempotent on (source, source_ref),
|
|
43
|
+
// but "harmless because something downstream catches it" is not a reason to
|
|
44
|
+
// leave it. Nothing here can affect the ship: the emitters fire post-commit and
|
|
45
|
+
// already wrap the emit, and `runCascadesForShip` resolves rather than throws.
|
|
46
|
+
let _cascadeListenerOff = null;
|
|
29
47
|
|
|
30
48
|
function registerSeams() {
|
|
31
49
|
if (!api.hasProvider('lifecycle')) {
|
|
32
50
|
api.registerProvider('lifecycle', lifecycle);
|
|
33
51
|
}
|
|
52
|
+
if (_cascadeListenerOff) _cascadeListenerOff();
|
|
53
|
+
_cascadeListenerOff = api.on('task.shipped', (shipped) => cascade.runCascadesForShip(shipped, {
|
|
54
|
+
getTask: (id) => db.getTask(id),
|
|
55
|
+
findTaskBySourceRef: (ref) => db.findTaskBySourceRef(ref),
|
|
56
|
+
createTask: (spec) => db.createTask(spec),
|
|
57
|
+
log: api.logger ? api.logger('lifecycle') : null,
|
|
58
|
+
}));
|
|
34
59
|
}
|
|
35
60
|
|
|
36
61
|
module.exports = function buildLifecycleRouter() {
|