@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.
@@ -3,9 +3,9 @@
3
3
  "generator": "scripts/gds/copy-inventory.js",
4
4
  "goal": 1000074,
5
5
  "counts": {
6
- "strings": 1646,
7
- "unique_ids": 1428,
8
- "certain": 1646,
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": 1188
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": 173,
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": 251,
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": 173,
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": 173,
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": 90,
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
- // Who a request is addressed to, in words. A craft request and a by-name
55
- // request read differently on purpose: "asked of builders" is a call anyone of
56
- // that craft may take, "asked of you" is personal and should feel it. A
57
- // by-name ask that is not yours names the person when the server joined a
58
- // name, and says "one person" when it did not.
59
- function addressedTo(req, viewerId) {
60
- if (req && req.needs_builder_id != null) {
61
- if (String(req.needs_builder_id) === String(viewerId)) return 'asked of you by name';
62
- var name = req.needs_builder_name || req.needs_builder_login;
63
- return name ? 'asked of ' + name + ' by name' : 'asked of one person';
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
- if (req && req.needs_craft) return 'asked of ' + req.needs_craft + 's';
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
- addressedTo: addressedTo, askerOf: askerOf, contextOf: contextOf,
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
- // Who to answer: the asker's name, as a door to their profile.
63
- function askerFact(req) {
64
- const name = L.askerOf(req);
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
- const who = req.requested_by != null
67
- ? `<a href="/builders/profile?id=${escapeHtml(String(req.requested_by))}">${escapeHtml(name)}</a>`
66
+ return id != null
67
+ ? `<a href="/builders/profile?id=${escapeHtml(String(id))}">${escapeHtml(name)}</a>`
68
68
  : escapeHtml(name);
69
- return `<span class="ov-fact">asked by ${who}</span>`;
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">${askerFact(req)}${contextChip(req)}</div>
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"><span class="ov-fact">${escapeHtml(L.addressedTo(req, null))}</span>${contextChip(req)}</div>
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() {