@bongos/core 1.19.837 → 1.19.838
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 +45 -20
- package/docs/adr/0302-the-third-identical-failure-stops-the-session-and-asks.md +74 -0
- package/docs/adr/README.md +1 -0
- package/docs/file-map.md +2 -0
- package/docs/module-api-changelog.md +2 -0
- package/modules/agents/migrations/agents_003_output_ref_idx.sql +41 -0
- package/modules/agents/module.json +1 -1
- package/modules/agents/pollers/event-router.js +737 -0
- package/modules/discord/module.json +1 -1
- package/modules/discord/routes/discord.js +25 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/plain-cards.js +2 -0
- package/src/module-api.js +1 -1
- package/tests/agents_event_router.mjs +523 -0
- package/tests/hooks_drain.mjs +1 -1
- package/tests/host_topology_skips.mjs +1 -0
- package/tests/stuck_detector.mjs +233 -0
|
@@ -0,0 +1,737 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// modules/agents/pollers/event-router.js — the EVENT half of the agents module
|
|
4
|
+
// (task 1002494, goal 1000095 / BONGOS-V2).
|
|
5
|
+
//
|
|
6
|
+
// `POST /agents/:name/invoke` fires an agent because a builder asked. This fires
|
|
7
|
+
// one because something HAPPENED: the lifecycle emits `task.shipped`, and every
|
|
8
|
+
// enabled definition whose `trigger_spec` names that event and whose declared
|
|
9
|
+
// `touches[]` filter covers what shipped gets one fire. It is the counterpart of
|
|
10
|
+
// gate.js — that file answers "may this be asked?", this one answers "should
|
|
11
|
+
// this be told?" — and it is the thing task 1002495's roster (review-lens,
|
|
12
|
+
// doc-link-keeper, dep-security-watch) needs in order to exist at all.
|
|
13
|
+
//
|
|
14
|
+
// It is filed under pollers/ because that is the loader's only boot hook for a
|
|
15
|
+
// module (src/module-loader/loader.js startModulePollers → `contributes.pollers`,
|
|
16
|
+
// declared in module.json). It owns no timer: `start()` subscribes and `stop()`
|
|
17
|
+
// unsubscribes. A "poller" here means "something the module runs in the web
|
|
18
|
+
// process", not "something on a clock".
|
|
19
|
+
//
|
|
20
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
21
|
+
// FOUR PROPERTIES, EACH FROM THE TASK'S OWN BRIEF, AND EACH LOAD-BEARING.
|
|
22
|
+
//
|
|
23
|
+
// 1. THE CHEAP FILTER RUNS FIRST — NEVER PAY FOR A SPAWN TO DISCOVER
|
|
24
|
+
// IRRELEVANCE. Nothing spawns until the definition's declared `touches[]`
|
|
25
|
+
// globs have been matched against what the shipped task actually touched. A
|
|
26
|
+
// fire is ~$0.90 and 60-120 seconds; a glob match is microseconds and no I/O.
|
|
27
|
+
// The order below is strictly cheapest-first for that reason: the indexed
|
|
28
|
+
// definition read, then the glob filter, then the loop guard, then the sink
|
|
29
|
+
// check, then the fan-out cap, then the spend ceiling, and only then a model.
|
|
30
|
+
//
|
|
31
|
+
// A PRE-FILTER MISS IS NOT LEDGERED, and that is a deliberate departure from
|
|
32
|
+
// the module's "every refusal is a row" rule. A miss is SELECTION, not
|
|
33
|
+
// refusal — the same category as the partial dispatch index skipping a
|
|
34
|
+
// disabled row, which also writes nothing. Ledgering it would put one row per
|
|
35
|
+
// non-matching agent on every ship forever and bury the refusals that matter.
|
|
36
|
+
// Everything downstream of the filter — the loop guard, an unwired sink, the
|
|
37
|
+
// fan-out cap, the spend ceiling — IS ledgered as a no-go, because each of
|
|
38
|
+
// those stops a fire that was otherwise going to happen.
|
|
39
|
+
//
|
|
40
|
+
// 2. THE DEPTH GUARD, OR AGENTS TRIGGER EACH OTHER FOREVER. An agent's finding
|
|
41
|
+
// is filed through `ideas.capture`; an idea becomes a task; a task ships; a
|
|
42
|
+
// ship is `task.shipped`. That is a closed loop, and without a guard it is an
|
|
43
|
+
// unbounded one that spends real money going round. So: a ship that is itself
|
|
44
|
+
// ATTRIBUTED to an `agents_runs` row fires nothing. Attribution is read off
|
|
45
|
+
// the ledger's own `output_ref` — the pointer a fire writes when its finding
|
|
46
|
+
// lands — never off a heuristic about the task's title or author.
|
|
47
|
+
//
|
|
48
|
+
// It FAILS CLOSED: an unreadable ledger, a missing task row, a throwing
|
|
49
|
+
// query all read as "attributed", i.e. do not fire. "I could not check
|
|
50
|
+
// whether this is a loop" must never resolve to "go ahead"; that is the exact
|
|
51
|
+
// failure lib/spend-ceiling.js's header dissects at length (task 1001393).
|
|
52
|
+
//
|
|
53
|
+
// 3. THE SINK IS CHECKED BEFORE THE SPAWN, NOT AFTER. A finding delivered
|
|
54
|
+
// nowhere is a fire nobody can read and money nobody got anything for, so an
|
|
55
|
+
// unwired or unavailable sink refuses BEFORE the model runs — property 1
|
|
56
|
+
// applied to the other end of the pipe. SINK_CATALOG is the WIRED set, not
|
|
57
|
+
// the imaginable set, exactly as validate.js's EVENT_CATALOG is — every
|
|
58
|
+
// entry is backed by a port that exists, and one that is wired but absent on
|
|
59
|
+
// this instance refuses by name rather than silently.
|
|
60
|
+
//
|
|
61
|
+
// 4. IT CAN NEVER BLOCK, SLOW, OR FAIL A SHIP. `seams.emitAsync` AWAITS each
|
|
62
|
+
// listener (src/module-seams.js), and the HTTP ship handler awaits the emit —
|
|
63
|
+
// so a listener that awaited a 120-second fire would hold the shipping
|
|
64
|
+
// builder's request open for two minutes. The subscribed handler is therefore
|
|
65
|
+
// SYNCHRONOUS and returns undefined: it kicks the fan-out off detached and
|
|
66
|
+
// returns instantly. Every error inside is caught and logged. Only the
|
|
67
|
+
// DB-gated Grader gates landing; agents are observe-only, and this file must
|
|
68
|
+
// not become a second gate by accident or by latency.
|
|
69
|
+
|
|
70
|
+
const api = require('../../../src/module-api');
|
|
71
|
+
const { spawnAgent } = require('../spawn');
|
|
72
|
+
const { createSpendCeiling, payerFor } = require('../lib/spend-ceiling');
|
|
73
|
+
|
|
74
|
+
const log = api.logger('agents');
|
|
75
|
+
|
|
76
|
+
// The event this router dispatches. One constant rather than a literal in three
|
|
77
|
+
// places, and a member of validate.js's EVENT_CATALOG by construction — a
|
|
78
|
+
// definition naming an event nothing dispatches is refused at validation, so
|
|
79
|
+
// this string and that catalog entry are two halves of one wiring.
|
|
80
|
+
const EVENT = 'task.shipped';
|
|
81
|
+
|
|
82
|
+
// ONE instance for the module, for the reason lib/fire-budget.js's header gives:
|
|
83
|
+
// a ceiling that splits when a second caller builds its own is not a ceiling.
|
|
84
|
+
// The routes file holds its own for the on-demand path; this is the event path's,
|
|
85
|
+
// and they read the same ledger, so the CAP is one number either way.
|
|
86
|
+
const spendCeiling = createSpendCeiling();
|
|
87
|
+
|
|
88
|
+
// How many agents ONE event may fire. The on-demand path is bounded by
|
|
89
|
+
// lib/fire-budget.js — a sliding window per BUILDER — and an event fire has no
|
|
90
|
+
// builder, so that ceiling cannot apply to it (lib/spend-ceiling.js's payerFor
|
|
91
|
+
// says so in as many words: "an event fire has no caller"). That leaves the
|
|
92
|
+
// monthly spend ceiling as the only bound, and its header names its own blind
|
|
93
|
+
// spot: fires already in flight are invisible to it, so the overshoot is
|
|
94
|
+
// "concurrent-fires × ~$0.90" and "what bounds the concurrent count is
|
|
95
|
+
// fire-budget". On this path nothing did. This is that missing half — a blast
|
|
96
|
+
// radius per event, not an accounting, and the same shape for the same reason.
|
|
97
|
+
const DEFAULT_FANOUT_MAX = 5;
|
|
98
|
+
|
|
99
|
+
function envInt(name, fallback) {
|
|
100
|
+
const raw = process.env[name];
|
|
101
|
+
if (!raw) return fallback;
|
|
102
|
+
const n = Number(raw);
|
|
103
|
+
return Number.isInteger(n) && n > 0 ? n : fallback;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const FANOUT_MAX = envInt('BONGOS_AGENTS_EVENT_FANOUT_MAX', DEFAULT_FANOUT_MAX);
|
|
107
|
+
|
|
108
|
+
// The columns one fire needs. Spelled out rather than SELECT *, for the reason
|
|
109
|
+
// routes/agents.js gives about its own COLUMNS list: a column added later must
|
|
110
|
+
// not reach a model prompt just because it exists. Only what a fire reads: `id` + `name` for the ledger, `persona` for
|
|
111
|
+
// the prompt, `trigger_spec` for the filter and the sink, `model_tier` for the
|
|
112
|
+
// spawn. Deliberately NOT `scope_violation` — a flagged definition is forced
|
|
113
|
+
// `enabled = false` by agents_definitions_flagged_not_armed_chk, so the WHERE
|
|
114
|
+
// clause below already excludes it and a second check here would only be a
|
|
115
|
+
// second place to get it wrong.
|
|
116
|
+
const DEF_COLUMNS = `
|
|
117
|
+
id, name, persona, trigger_type, trigger_spec, model_tier
|
|
118
|
+
`;
|
|
119
|
+
|
|
120
|
+
// ---------------------------------------------------------------------------
|
|
121
|
+
// THE PRE-FILTER (pure)
|
|
122
|
+
// ---------------------------------------------------------------------------
|
|
123
|
+
|
|
124
|
+
// The declared filter, read off `trigger_spec`. `touches` is the documented
|
|
125
|
+
// spelling; `touches_globs` is accepted as the same thing because the field is
|
|
126
|
+
// named after the task column it matches against and both spellings have been
|
|
127
|
+
// written down in the goal's own prose. Anything that is not an array of
|
|
128
|
+
// non-empty strings is treated as ABSENT rather than as an error: a malformed
|
|
129
|
+
// filter must not silently narrow to "matches nothing", which would disarm an
|
|
130
|
+
// armed agent with no trace. It widens to "no filter declared", which is visible
|
|
131
|
+
// in the registry and in this file's log line.
|
|
132
|
+
function declaredGlobs(definition) {
|
|
133
|
+
const spec = definition && definition.trigger_spec;
|
|
134
|
+
if (!spec || typeof spec !== 'object') return [];
|
|
135
|
+
const raw = Array.isArray(spec.touches) ? spec.touches
|
|
136
|
+
: Array.isArray(spec.touches_globs) ? spec.touches_globs
|
|
137
|
+
: [];
|
|
138
|
+
return raw.filter((g) => typeof g === 'string' && g.trim() !== '');
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Does this definition's declared filter cover what the task touched? PURE.
|
|
143
|
+
*
|
|
144
|
+
* NO DECLARED FILTER ⇒ FIRES ON EVERY SHIP. An absent filter is a definition
|
|
145
|
+
* that declared no narrowing, and inventing one for it would make an armed
|
|
146
|
+
* agent inert for reasons nothing records. Arming a universal agent is a
|
|
147
|
+
* deliberate act by a Metic+ author (the `agent.arm` atom), it is visible in
|
|
148
|
+
* `GET /agents`, and its cost is bounded by the fan-out cap and the spend
|
|
149
|
+
* ceiling — which is the honest place for that bound to live.
|
|
150
|
+
*
|
|
151
|
+
* A DECLARED FILTER AGAINST AN EMPTY `touches[]` ⇒ NO FIRE. A task that declared
|
|
152
|
+
* no blast radius offers no evidence that it is in the agent's scope, and this
|
|
153
|
+
* is the path where "no evidence" must read as "do not spend" — the cheap-filter
|
|
154
|
+
* rule of property 1. `touches[]` is advisory on a task (ADR 0049), so this is
|
|
155
|
+
* the right direction of error: a missed fire costs nothing and shows up as an
|
|
156
|
+
* agent that stayed quiet; a paid fire on an unrelated ship costs ~$0.90 and
|
|
157
|
+
* shows up as noise in the inbox.
|
|
158
|
+
*
|
|
159
|
+
* @param {object} definition the agents_definitions row.
|
|
160
|
+
* @param {string[]} taskTouches the shipped task's touches[].
|
|
161
|
+
* @param {(globs: string[]) => (file: string) => boolean} compile the matcher
|
|
162
|
+
* factory, injected so this stays pure and testable with no core require.
|
|
163
|
+
* @returns {{match: boolean, reason: string}}
|
|
164
|
+
*/
|
|
165
|
+
function matchesTouches(definition, taskTouches, compile) {
|
|
166
|
+
const globs = declaredGlobs(definition);
|
|
167
|
+
if (globs.length === 0) return { match: true, reason: 'no_filter_declared' };
|
|
168
|
+
const paths = (Array.isArray(taskTouches) ? taskTouches : []).filter(
|
|
169
|
+
(p) => typeof p === 'string' && p.trim() !== '',
|
|
170
|
+
);
|
|
171
|
+
if (paths.length === 0) return { match: false, reason: 'task_declared_no_touches' };
|
|
172
|
+
let matcher;
|
|
173
|
+
try {
|
|
174
|
+
matcher = compile(globs);
|
|
175
|
+
} catch (_) {
|
|
176
|
+
// A filter that cannot be compiled is a filter nobody can trust. Refuse the
|
|
177
|
+
// fire rather than fall back to matching everything, which would turn a typo
|
|
178
|
+
// in one definition into a paid fire on every ship.
|
|
179
|
+
return { match: false, reason: 'filter_uncompilable' };
|
|
180
|
+
}
|
|
181
|
+
for (const p of paths) {
|
|
182
|
+
let hit = false;
|
|
183
|
+
try { hit = matcher(p) === true; } catch (_) { hit = false; }
|
|
184
|
+
if (hit) return { match: true, reason: 'touches_match' };
|
|
185
|
+
}
|
|
186
|
+
return { match: false, reason: 'no_touches_overlap' };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Split the candidate definitions into the ones that fire and the ones the
|
|
191
|
+
* pre-filter dropped. PURE — the whole selection decision, testable with no
|
|
192
|
+
* database, no model and no server.
|
|
193
|
+
*/
|
|
194
|
+
function selectFiring(definitions, taskTouches, compile) {
|
|
195
|
+
const firing = [];
|
|
196
|
+
const filtered = [];
|
|
197
|
+
for (const d of definitions || []) {
|
|
198
|
+
const verdict = matchesTouches(d, taskTouches, compile);
|
|
199
|
+
(verdict.match ? firing : filtered).push({ definition: d, reason: verdict.reason });
|
|
200
|
+
}
|
|
201
|
+
return { firing, filtered };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// ---------------------------------------------------------------------------
|
|
205
|
+
// THE DEPTH GUARD
|
|
206
|
+
// ---------------------------------------------------------------------------
|
|
207
|
+
|
|
208
|
+
// The `output_ref` spellings that would mean "an agent produced this". PURE.
|
|
209
|
+
//
|
|
210
|
+
// Two refs, because a finding reaches a task by two hops and the guard has to
|
|
211
|
+
// cover both ends:
|
|
212
|
+
// `task:<id>` — a fire whose output landed as this task directly.
|
|
213
|
+
// `idea:<id>` — a fire whose finding landed as an idea; `/idea-triage` later
|
|
214
|
+
// promoted it, and the promotion stamps `tasks.source` as
|
|
215
|
+
// 'idea-<id>' (that is how every promoted task on this instance
|
|
216
|
+
// is spelled). So the idea id is recoverable from the task row
|
|
217
|
+
// with no cross-module table read — this module may not go
|
|
218
|
+
// reading `idea_inbox`, and does not have to.
|
|
219
|
+
function depthRefsFor(taskRow) {
|
|
220
|
+
const refs = [];
|
|
221
|
+
const id = taskRow && taskRow.id;
|
|
222
|
+
if (id !== null && id !== undefined && String(id) !== '') refs.push(`task:${id}`);
|
|
223
|
+
const source = taskRow && typeof taskRow.source === 'string' ? taskRow.source : '';
|
|
224
|
+
const m = source.match(/^idea-(\d+)$/);
|
|
225
|
+
if (m) refs.push(`idea:${m[1]}`);
|
|
226
|
+
return refs;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Is this ship attributable to an agent fire? FAILS CLOSED — an unanswerable
|
|
231
|
+
* question returns `true`, i.e. "do not fire".
|
|
232
|
+
*
|
|
233
|
+
* @returns {{attributed: boolean, reason: string|null}}
|
|
234
|
+
*/
|
|
235
|
+
async function isAgentAttributed(pool, taskRow) {
|
|
236
|
+
const refs = depthRefsFor(taskRow);
|
|
237
|
+
if (refs.length === 0) {
|
|
238
|
+
// No id to ask about. Not knowing which task shipped is not a reason to
|
|
239
|
+
// spend; it is a reason to stop.
|
|
240
|
+
return { attributed: true, reason: 'the shipped task could not be identified, so the loop guard could not be evaluated' };
|
|
241
|
+
}
|
|
242
|
+
if (!pool || typeof pool.query !== 'function') {
|
|
243
|
+
return { attributed: true, reason: 'the agents_runs ledger was unreadable, so the loop guard could not be evaluated' };
|
|
244
|
+
}
|
|
245
|
+
try {
|
|
246
|
+
const { rows } = await pool.query(
|
|
247
|
+
`SELECT id, agent_name FROM agents_runs
|
|
248
|
+
WHERE output_ref = ANY($1::text[])
|
|
249
|
+
LIMIT 1`,
|
|
250
|
+
[refs],
|
|
251
|
+
);
|
|
252
|
+
if (rows && rows.length) {
|
|
253
|
+
return {
|
|
254
|
+
attributed: true,
|
|
255
|
+
reason: `this ship is attributed to agent run ${rows[0].id} (${rows[0].agent_name}) — firing on an agent's own output is the loop this guard exists to stop`,
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
return { attributed: false, reason: null };
|
|
259
|
+
} catch (_) {
|
|
260
|
+
return { attributed: true, reason: 'the agents_runs ledger read threw, so the loop guard could not be evaluated' };
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// ---------------------------------------------------------------------------
|
|
265
|
+
// THE SINKS
|
|
266
|
+
// ---------------------------------------------------------------------------
|
|
267
|
+
|
|
268
|
+
// WIRED, not imaginable — the same rule and the same reason as validate.js's
|
|
269
|
+
// EVENT_CATALOG: a definition naming a sink nothing delivers to looks armed and
|
|
270
|
+
// can never report, which is the worst of both readings. Both entries below are
|
|
271
|
+
// backed by a port that exists; a name with no port behind it does not belong
|
|
272
|
+
// here, and `resolveSink` refuses one that is wired but absent on this instance.
|
|
273
|
+
//
|
|
274
|
+
// The two differ in KIND, not just destination, and a definition picks by what
|
|
275
|
+
// it wants done with the finding:
|
|
276
|
+
//
|
|
277
|
+
// `ideas.capture` the finding enters the BACKLOG as an inbox row a human
|
|
278
|
+
// triages. Filed with no `vector`, so routing.js leaves it an
|
|
279
|
+
// idea and never a claimable task.
|
|
280
|
+
// `discord` the finding is ANNOUNCED and nothing more. Discord is a
|
|
281
|
+
// one-way mirror and never authority (ADR 0033), which is
|
|
282
|
+
// also why this sink cannot feed the loop the depth guard
|
|
283
|
+
// watches: a message becomes no record here, so there is
|
|
284
|
+
// nothing for a later ship to be attributed to.
|
|
285
|
+
const SINK_CATALOG = Object.freeze({
|
|
286
|
+
'ideas.capture': Object.freeze({ port: 'ideas.capture', deliver: deliverToInbox }),
|
|
287
|
+
discord: Object.freeze({ port: 'discord.post', deliver: deliverToDiscord }),
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
const DEFAULT_SINK = 'ideas.capture';
|
|
291
|
+
|
|
292
|
+
// Which sink did this definition declare? PURE. Absent ⇒ the default, because a
|
|
293
|
+
// finding with nowhere to go is the one outcome worth ruling out by construction.
|
|
294
|
+
function sinkNameFor(definition) {
|
|
295
|
+
const spec = definition && definition.trigger_spec;
|
|
296
|
+
const raw = spec && typeof spec === 'object' ? spec.sink : null;
|
|
297
|
+
return typeof raw === 'string' && raw.trim() !== '' ? raw.trim() : DEFAULT_SINK;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Resolve the declared sink to something that can actually take a finding, or
|
|
302
|
+
* say why not. Called BEFORE the spawn — property 3.
|
|
303
|
+
*
|
|
304
|
+
* @returns {{ok: true, name: string, deliver: Function} | {ok: false, name: string, reason: string}}
|
|
305
|
+
*/
|
|
306
|
+
function resolveSink(definition, deps = {}) {
|
|
307
|
+
const name = sinkNameFor(definition);
|
|
308
|
+
const entry = SINK_CATALOG[name];
|
|
309
|
+
if (!entry) {
|
|
310
|
+
return {
|
|
311
|
+
ok: false,
|
|
312
|
+
name,
|
|
313
|
+
reason: `sink '${name}' is not wired — this instance delivers findings to: ${Object.keys(SINK_CATALOG).join(', ')}. Refused before spawning rather than after: a finding nobody receives is money spent for nothing.`,
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
const resolveOptional = deps.resolveOptional
|
|
317
|
+
|| (typeof api.resolveOptional === 'function' ? api.resolveOptional.bind(api) : null);
|
|
318
|
+
const port = resolveOptional ? resolveOptional(entry.port, null) : null;
|
|
319
|
+
if (typeof port !== 'function') {
|
|
320
|
+
return {
|
|
321
|
+
ok: false,
|
|
322
|
+
name,
|
|
323
|
+
reason: `sink '${name}' is wired but the '${entry.port}' port is not available on this instance (the module providing it is disabled) — refused before spawning.`,
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
return { ok: true, name, deliver: port, entry };
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// Everything a sink is handed is UNTRUSTED TEXT, from two directions at once:
|
|
330
|
+
// `task.title` is builder-authored and can arrive from the idea inbox, and the
|
|
331
|
+
// finding is raw model output generated from a repo the agent read. Both are
|
|
332
|
+
// bounded and stripped of control characters HERE, once, so neither sink has to
|
|
333
|
+
// remember to — and so nothing that reaches a rendering surface can carry an
|
|
334
|
+
// ANSI escape, a NUL, or an unbounded blob. This is a floor, not an escaping
|
|
335
|
+
// layer: HTML escaping belongs to whatever renders the row, and this file makes
|
|
336
|
+
// no claim about it.
|
|
337
|
+
const TITLE_MAX = 180;
|
|
338
|
+
const BODY_MAX = 8000;
|
|
339
|
+
|
|
340
|
+
function clean(value, max) {
|
|
341
|
+
// Scanned by CODE POINT rather than matched by a regex character class: the
|
|
342
|
+
// class would have to spell control characters as escape sequences, and a
|
|
343
|
+
// source file that spells them is one editor away from CONTAINING them —
|
|
344
|
+
// which is how this file first shipped as a binary blob git would not diff.
|
|
345
|
+
// Tab and newline survive; everything else below 0x20, plus DEL, becomes a
|
|
346
|
+
// space.
|
|
347
|
+
const s = String(value == null ? '' : value);
|
|
348
|
+
let out = '';
|
|
349
|
+
for (const ch of s) {
|
|
350
|
+
const c = ch.codePointAt(0);
|
|
351
|
+
const control = (c < 0x20 && c !== 0x0a && c !== 0x09) || c === 0x7f;
|
|
352
|
+
out += control ? ' ' : ch;
|
|
353
|
+
}
|
|
354
|
+
return out.trim().slice(0, max);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// The two lines every finding carries, whichever sink takes it: WHO said it,
|
|
358
|
+
// WHAT it was looking at, and WHEN. Composed once so the two deliveries cannot
|
|
359
|
+
// describe the same fire differently.
|
|
360
|
+
function findingParts({ definition, task, text }) {
|
|
361
|
+
return {
|
|
362
|
+
title: clean(`${definition.name}: ${task.title || `task ${task.id}`}`, TITLE_MAX),
|
|
363
|
+
provenance: `Reported by the \`${definition.name}\` agent on \`${EVENT}\` for task ${task.id}.`,
|
|
364
|
+
body: clean(text, BODY_MAX),
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// The BACKLOG sink. NO `vector` IS PASSED, and that is the observe-only property
|
|
369
|
+
// in one argument: modules/ideas/routing.js routes a capture into a real task
|
|
370
|
+
// only for a filing vector on its own allowlist, so an absent vector is
|
|
371
|
+
// UNTRUSTED and the finding stays an inbox row for a human to triage. An agent
|
|
372
|
+
// may report, and may not manufacture claimable work — the same posture as the
|
|
373
|
+
// read-only spawn, enforced at the other end of the pipe.
|
|
374
|
+
//
|
|
375
|
+
// Returns the POINTER, never the text: `agents_runs.output_ref` is a pointer by
|
|
376
|
+
// schema contract (see the migration's header), and `idea:<id>` is the spelling
|
|
377
|
+
// the depth guard reads back.
|
|
378
|
+
async function deliverToInbox(deliver, parts) {
|
|
379
|
+
const idea = await deliver({
|
|
380
|
+
title: parts.title,
|
|
381
|
+
bodyMd: `${parts.provenance}\n\n${parts.body}`,
|
|
382
|
+
capturedBy: null,
|
|
383
|
+
});
|
|
384
|
+
const id = idea && idea.id;
|
|
385
|
+
return id !== null && id !== undefined ? `idea:${id}` : null;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// The ANNOUNCE sink. Discord is a one-way mirror and never authority (ADR 0033).
|
|
389
|
+
//
|
|
390
|
+
// THE CHANNEL IS DECIDED HERE, NEVER BY THE DEFINITION. An agent that could name
|
|
391
|
+
// its own channel could choose where it is heard, and the one choice that
|
|
392
|
+
// actually matters is the security one: a finding about a SECURITY-SENSITIVE
|
|
393
|
+
// ship must not advertise the shape of an unpatched exploit on a channel the
|
|
394
|
+
// whole guild reads. So this mirrors ship-broadcast.chooseBroadcastTargets' rule
|
|
395
|
+
// verbatim, including its fail-closed arm — a `security_sensitive` that is not a
|
|
396
|
+
// definite boolean SUPPRESSES the post rather than guessing, because "we could
|
|
397
|
+
// not tell whether this was a security fix" must never resolve to "post it
|
|
398
|
+
// publicly".
|
|
399
|
+
function discordChannelFor(task) {
|
|
400
|
+
const sec = task && task.security_sensitive;
|
|
401
|
+
if (sec === true) return 'security_ship_feed';
|
|
402
|
+
if (sec === false) return 'ship_feed';
|
|
403
|
+
return null;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
async function deliverToDiscord(deliver, parts, { task }) {
|
|
407
|
+
const channel = discordChannelFor(task);
|
|
408
|
+
if (!channel) return null;
|
|
409
|
+
const res = await deliver({
|
|
410
|
+
channel,
|
|
411
|
+
text: `${parts.title}\n${parts.provenance}\n${parts.body}`,
|
|
412
|
+
});
|
|
413
|
+
// A post that did not happen is not a pointer. `discord.post` answers
|
|
414
|
+
// `{posted:false}` when the operator configured no webhook for the channel,
|
|
415
|
+
// and recording a pointer to a message that does not exist would be a lie the
|
|
416
|
+
// ledger cannot be talked out of later.
|
|
417
|
+
return res && res.posted === true ? `discord:${channel}:${task.id}` : null;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// Deliver one finding through whichever sink resolved, and return the POINTER to
|
|
421
|
+
// where it landed.
|
|
422
|
+
async function deliverFinding(sink, { definition, task, text }) {
|
|
423
|
+
const parts = findingParts({ definition, task, text });
|
|
424
|
+
const deliver = sink.entry && typeof sink.entry.deliver === 'function' ? sink.entry.deliver : deliverToInbox;
|
|
425
|
+
return deliver(sink.deliver, parts, { task, definition });
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// ---------------------------------------------------------------------------
|
|
429
|
+
// THE FAN-OUT
|
|
430
|
+
// ---------------------------------------------------------------------------
|
|
431
|
+
|
|
432
|
+
// The deps spawnAgent is handed. The POOL is threaded explicitly rather than
|
|
433
|
+
// left to spawnAgent's `api.pool` fallback, so a caller that injected a fake pool
|
|
434
|
+
// for the reads above does not silently get the real one for the ledger writes —
|
|
435
|
+
// a test that half-injects is a test that writes rows into a live database.
|
|
436
|
+
function spawnDepsFrom(deps = {}) {
|
|
437
|
+
const extra = deps.spawnDeps || {};
|
|
438
|
+
return deps.pool && !extra.pool ? { ...extra, pool: deps.pool } : { ...extra };
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// Record a fire that is NOT going to happen, using the same spawn seam the real
|
|
442
|
+
// ones use. spawnAgent writes the no-go row and returns without spawning, so
|
|
443
|
+
// this is a database round trip and never a model call — and routing it through
|
|
444
|
+
// spawnAgent rather than an INSERT here is what keeps ONE ledger contract in
|
|
445
|
+
// ONE place (the second copy is how two ledgers start disagreeing).
|
|
446
|
+
async function ledgerNoGo(deps, { definition, taskId, reason, spendPayer }) {
|
|
447
|
+
try {
|
|
448
|
+
return await spawnAgent({
|
|
449
|
+
definition,
|
|
450
|
+
trigger: { type: 'event', ref: `${EVENT}:${taskId}` },
|
|
451
|
+
gate: { decision: 'no-go', reason },
|
|
452
|
+
requestedByBuilderId: null,
|
|
453
|
+
spendPayer,
|
|
454
|
+
}, spawnDepsFrom(deps));
|
|
455
|
+
} catch (err) {
|
|
456
|
+
// spawnAgent does not throw, but this whole file's contract is that nothing
|
|
457
|
+
// reaches the emitter. A ledger write that failed is logged, not raised.
|
|
458
|
+
log.error({ err, agent: definition && definition.name }, 'agents/event-router: no-go ledger write failed');
|
|
459
|
+
return null;
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* The whole reaction to one shipped task. Exported and awaitable so a test can
|
|
465
|
+
* drive it end to end; the subscribed handler never awaits it (property 4).
|
|
466
|
+
*
|
|
467
|
+
* Returns a REPORT rather than throwing — the same shape the cascade runner
|
|
468
|
+
* returns for the same reason (ADR 0242): a reaction that could fail the thing
|
|
469
|
+
* it reacted to would smuggle a gate in through the back door.
|
|
470
|
+
*/
|
|
471
|
+
async function fanOut(payload, deps = {}) {
|
|
472
|
+
const report = {
|
|
473
|
+
task_id: null, considered: 0, filtered: 0, fired: 0, skipped: 0, reason: null,
|
|
474
|
+
};
|
|
475
|
+
const pool = deps.pool || api.pool;
|
|
476
|
+
const compile = deps.compileGlobMatcher
|
|
477
|
+
|| ((globs) => api.permissionPathCheck.compileGlobMatcher(globs));
|
|
478
|
+
const spendPayer = deps.spendPayer !== undefined ? deps.spendPayer : projectSpendPayer();
|
|
479
|
+
|
|
480
|
+
const taskId = payload && payload.id !== undefined && payload.id !== null ? String(payload.id) : null;
|
|
481
|
+
report.task_id = taskId;
|
|
482
|
+
if (!taskId) { report.reason = 'no_task_id'; return report; }
|
|
483
|
+
|
|
484
|
+
// (a) THE DEFINITION READ, FIRST AND CHEAPEST. It rides the partial dispatch
|
|
485
|
+
// index agents_001 built for exactly this query (`WHERE enabled`), and on the
|
|
486
|
+
// overwhelmingly common instance — agents shipped, nothing armed — it returns
|
|
487
|
+
// zero rows and the whole reaction ends here, having read no task, spent
|
|
488
|
+
// nothing and written nothing.
|
|
489
|
+
let definitions = [];
|
|
490
|
+
try {
|
|
491
|
+
const { rows } = await pool.query(
|
|
492
|
+
`SELECT ${DEF_COLUMNS} FROM agents_definitions
|
|
493
|
+
WHERE enabled AND trigger_type = 'event' AND trigger_spec ->> 'event' = $1
|
|
494
|
+
ORDER BY id`,
|
|
495
|
+
[EVENT],
|
|
496
|
+
);
|
|
497
|
+
definitions = rows || [];
|
|
498
|
+
} catch (err) {
|
|
499
|
+
// Nothing is armed as far as we can tell, so nothing fires and nothing is
|
|
500
|
+
// ledgered — there is no definition to attribute a row to.
|
|
501
|
+
log.error({ err, task_id: taskId }, 'agents/event-router: definition read failed; no agent fired');
|
|
502
|
+
report.reason = 'definitions_unreadable';
|
|
503
|
+
return report;
|
|
504
|
+
}
|
|
505
|
+
report.considered = definitions.length;
|
|
506
|
+
if (definitions.length === 0) { report.reason = 'no_armed_event_agents'; return report; }
|
|
507
|
+
|
|
508
|
+
// (b) THE TASK ROW. The `task.shipped` payload is db-ship.js's RETURNING
|
|
509
|
+
// projection — id, title, status, shipped_at, shipped_by, security_sensitive,
|
|
510
|
+
// value_summary, credits_reward, kind — and it carries NEITHER `touches` nor
|
|
511
|
+
// `source`, which are exactly the two fields the pre-filter and the loop guard
|
|
512
|
+
// read. So this re-reads them rather than trusting a projection that was never
|
|
513
|
+
// built for this consumer. Widening the emit instead would have coupled the
|
|
514
|
+
// lifecycle module's ship shape to this module's needs.
|
|
515
|
+
let task = null;
|
|
516
|
+
try {
|
|
517
|
+
const { rows } = await pool.query(
|
|
518
|
+
`SELECT id, title, touches, source, security_sensitive FROM tasks WHERE id = $1`, [taskId],
|
|
519
|
+
);
|
|
520
|
+
task = rows && rows[0] ? rows[0] : null;
|
|
521
|
+
} catch (_) { task = null; }
|
|
522
|
+
if (!task) {
|
|
523
|
+
// FAIL CLOSED. With no touches[] the pre-filter cannot run, and firing every
|
|
524
|
+
// armed agent on an unreadable task is the expensive direction of that
|
|
525
|
+
// error. Ledgered, unlike a filter miss: these fires WOULD have happened.
|
|
526
|
+
const reason = 'the shipped task row could not be read, so the touches[] pre-filter could not run — refused rather than firing every armed agent blind';
|
|
527
|
+
for (const d of definitions) await ledgerNoGo(deps, { definition: d, taskId, reason, spendPayer });
|
|
528
|
+
report.skipped = definitions.length;
|
|
529
|
+
report.reason = 'task_unreadable';
|
|
530
|
+
return report;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
// (c) THE CHEAP FILTER — property 1. No I/O, no ledger rows for the misses.
|
|
534
|
+
const { firing, filtered } = selectFiring(definitions, task.touches, compile);
|
|
535
|
+
report.filtered = filtered.length;
|
|
536
|
+
if (firing.length === 0) { report.reason = 'no_agent_matched_touches'; return report; }
|
|
537
|
+
|
|
538
|
+
// (d) THE DEPTH GUARD — property 2.
|
|
539
|
+
const depth = await isAgentAttributed(pool, task);
|
|
540
|
+
if (depth.attributed) {
|
|
541
|
+
for (const f of firing) {
|
|
542
|
+
await ledgerNoGo(deps, { definition: f.definition, taskId, reason: depth.reason, spendPayer });
|
|
543
|
+
}
|
|
544
|
+
report.skipped = firing.length;
|
|
545
|
+
report.reason = 'depth_guard';
|
|
546
|
+
return report;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
// (e) THE SINK CHECK — property 3, before any spawn.
|
|
550
|
+
const deliverable = [];
|
|
551
|
+
for (const f of firing) {
|
|
552
|
+
const sink = resolveSink(f.definition, deps);
|
|
553
|
+
if (!sink.ok) {
|
|
554
|
+
await ledgerNoGo(deps, { definition: f.definition, taskId, reason: sink.reason, spendPayer });
|
|
555
|
+
report.skipped += 1;
|
|
556
|
+
continue;
|
|
557
|
+
}
|
|
558
|
+
deliverable.push({ definition: f.definition, sink });
|
|
559
|
+
}
|
|
560
|
+
if (deliverable.length === 0) { report.reason = 'no_deliverable_sink'; return report; }
|
|
561
|
+
|
|
562
|
+
// (f) THE FAN-OUT CAP. Ordered by id, so which agents survive a cap is stable
|
|
563
|
+
// and reviewable rather than whatever the planner happened to return first.
|
|
564
|
+
const admitted = deliverable.slice(0, FANOUT_MAX);
|
|
565
|
+
for (const over of deliverable.slice(FANOUT_MAX)) {
|
|
566
|
+
await ledgerNoGo(deps, {
|
|
567
|
+
definition: over.definition,
|
|
568
|
+
taskId,
|
|
569
|
+
reason: `one event may fire at most ${FANOUT_MAX} agents (BONGOS_AGENTS_EVENT_FANOUT_MAX) and ${deliverable.length} matched — a blast radius per event, since an event fire has no builder for the per-builder rate ceiling to bound`,
|
|
570
|
+
spendPayer,
|
|
571
|
+
});
|
|
572
|
+
report.skipped += 1;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
// (g) THE SPEND CEILING, once for the whole fan-out. Per PAYER, and an event
|
|
576
|
+
// fire's payer is always the project (lib/spend-ceiling.js payerFor). It fails
|
|
577
|
+
// closed, including on an unreadable ledger.
|
|
578
|
+
const payer = payerFor(spendPayer, null);
|
|
579
|
+
const ceiling = deps.spendCeiling || spendCeiling;
|
|
580
|
+
const room = await ceiling.check(pool, payer);
|
|
581
|
+
if (!room.ok) {
|
|
582
|
+
const reason = room.reason === 'spend_cap_reached'
|
|
583
|
+
? `agent spend for this ${payer.payer_kind} has reached its $${room.capUsd} monthly cap`
|
|
584
|
+
: `the agent spend ceiling could not be established (${room.reason}) — a ceiling that cannot be read must not be assumed`;
|
|
585
|
+
for (const a of admitted) {
|
|
586
|
+
await ledgerNoGo(deps, { definition: a.definition, taskId, reason, spendPayer });
|
|
587
|
+
}
|
|
588
|
+
report.skipped += admitted.length;
|
|
589
|
+
report.reason = 'spend_ceiling';
|
|
590
|
+
return report;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
// (h) FIRE. Sequentially, so N agents on one ship are N model calls in a row
|
|
594
|
+
// rather than N at once — the spend ceiling's own blind spot is fires already
|
|
595
|
+
// in flight, and serialising is what keeps that blind spot one fire wide.
|
|
596
|
+
for (const a of admitted) {
|
|
597
|
+
const fired = await fireOne(a, { task, taskId, spendPayer }, deps);
|
|
598
|
+
if (fired && fired.ran) report.fired += 1; else report.skipped += 1;
|
|
599
|
+
}
|
|
600
|
+
report.reason = 'fired';
|
|
601
|
+
return report;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
// One fire, plus the delivery of whatever it said.
|
|
605
|
+
//
|
|
606
|
+
// The delivery happens INSIDE the wrapped runner, so the pointer it produces is
|
|
607
|
+
// written by the SAME `finishRun` update that records the run's cost and
|
|
608
|
+
// duration. Doing it after `spawnAgent` returned would mean a second UPDATE and
|
|
609
|
+
// a window in which the ledger says a fire succeeded and names nowhere its
|
|
610
|
+
// finding went — which is the window the depth guard reads across.
|
|
611
|
+
async function fireOne({ definition, sink }, { task, taskId, spendPayer }, deps = {}) {
|
|
612
|
+
let outputRef = null;
|
|
613
|
+
let deliveryError = null;
|
|
614
|
+
|
|
615
|
+
const runner = async (args) => {
|
|
616
|
+
const resolveOptional = deps.resolveOptional
|
|
617
|
+
|| (typeof api.resolveOptional === 'function' ? api.resolveOptional.bind(api) : null);
|
|
618
|
+
const grade = resolveOptional ? resolveOptional('grade', null) : null;
|
|
619
|
+
const run = deps.runSubagentCached
|
|
620
|
+
|| (grade && typeof grade.runSubagentCached === 'function' ? grade.runSubagentCached.bind(grade) : null);
|
|
621
|
+
if (!run) throw Object.assign(new Error('grade port unavailable'), { code: 'grade_port_unavailable' });
|
|
622
|
+
const out = await run(args);
|
|
623
|
+
if (out && out.ok === false) return out;
|
|
624
|
+
const text = out && typeof out.stdout === 'string' ? out.stdout.trim() : '';
|
|
625
|
+
// A fire that found nothing to say is a SUCCESS with no pointer, not a
|
|
626
|
+
// failure. An observe-and-report agent reporting "nothing here" is the
|
|
627
|
+
// outcome most of its fires should have, and filing an empty idea for each
|
|
628
|
+
// one would make the inbox the noise this module is supposed to reduce.
|
|
629
|
+
if (!text) return out;
|
|
630
|
+
try {
|
|
631
|
+
outputRef = await deliverFinding(sink, { definition, task, text });
|
|
632
|
+
} catch (err) {
|
|
633
|
+
// The fire ran and cost money; the finding reached nobody. That is an
|
|
634
|
+
// ERROR row, not an ok one — a finding that silently evaporated is
|
|
635
|
+
// indistinguishable from an agent that found nothing, and those two must
|
|
636
|
+
// never look the same. The cost is still recorded either way.
|
|
637
|
+
deliveryError = err;
|
|
638
|
+
log.error({ err, agent: definition.name, task_id: taskId }, 'agents/event-router: sink delivery failed');
|
|
639
|
+
return { ...out, ok: false, error_code: 'sink_delivery_failed' };
|
|
640
|
+
}
|
|
641
|
+
return out;
|
|
642
|
+
};
|
|
643
|
+
|
|
644
|
+
const result = await spawnAgent({
|
|
645
|
+
definition,
|
|
646
|
+
trigger: { type: 'event', ref: `${EVENT}:${taskId}` },
|
|
647
|
+
gate: { decision: 'go' },
|
|
648
|
+
requestedByBuilderId: null,
|
|
649
|
+
spendPayer,
|
|
650
|
+
}, {
|
|
651
|
+
...spawnDepsFrom(deps),
|
|
652
|
+
runSubagentCached: runner,
|
|
653
|
+
// Read by spawnAgent AFTER the runner resolved, so the pointer the delivery
|
|
654
|
+
// above produced is the one that lands on the row. A getter rather than a
|
|
655
|
+
// value for exactly that reason: the value does not exist yet at call time.
|
|
656
|
+
get outputRef() { return outputRef; },
|
|
657
|
+
});
|
|
658
|
+
|
|
659
|
+
if (deliveryError) log.warn({ agent: definition.name, run_id: result && result.runId }, 'agents/event-router: fire recorded as error because its finding could not be delivered');
|
|
660
|
+
return result;
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
// The project's `spend_payer` policy, read the way routes/agents.js reads it and
|
|
664
|
+
// for the same reason — branding already memoizes for the process, so a second
|
|
665
|
+
// cache here would only be a way for the two to disagree.
|
|
666
|
+
function projectSpendPayer() {
|
|
667
|
+
try {
|
|
668
|
+
const pack = api.branding ? api.branding() : null;
|
|
669
|
+
return (pack && pack.project && pack.project.spendPayer) || null;
|
|
670
|
+
} catch (_) {
|
|
671
|
+
return null;
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
// ---------------------------------------------------------------------------
|
|
676
|
+
// THE SUBSCRIPTION
|
|
677
|
+
// ---------------------------------------------------------------------------
|
|
678
|
+
|
|
679
|
+
let _off = null;
|
|
680
|
+
|
|
681
|
+
// THE HANDLER IS SYNCHRONOUS ON PURPOSE — property 4. `emitAsync` awaits what a
|
|
682
|
+
// listener returns, and the ship route awaits the emit, so returning a promise
|
|
683
|
+
// here would hold a shipping builder's HTTP request open for the length of a
|
|
684
|
+
// model call. It returns undefined, and the fan-out runs detached behind it.
|
|
685
|
+
function onShipped(task) {
|
|
686
|
+
try {
|
|
687
|
+
const p = fanOut(task);
|
|
688
|
+
if (p && typeof p.catch === 'function') {
|
|
689
|
+
p.catch((err) => log.error({ err }, 'agents/event-router: fan-out rejected'));
|
|
690
|
+
}
|
|
691
|
+
} catch (err) {
|
|
692
|
+
// fanOut is written not to throw synchronously; this is the belt to that
|
|
693
|
+
// brace. A reactor must never break the actor.
|
|
694
|
+
log.error({ err }, 'agents/event-router: fan-out threw');
|
|
695
|
+
}
|
|
696
|
+
return undefined;
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
// off→on replacement rather than an unconditional subscribe, the same idiom
|
|
700
|
+
// modules/discord/routes/discord.js uses: a test (or a re-boot of the loader in
|
|
701
|
+
// one process) must not accumulate duplicate listeners, which would double every
|
|
702
|
+
// fire and every bill.
|
|
703
|
+
function start() {
|
|
704
|
+
if (_off) return;
|
|
705
|
+
_off = api.on(EVENT, onShipped);
|
|
706
|
+
log.info(
|
|
707
|
+
`[agents/event-router] listening on ${EVENT} — up to ${FANOUT_MAX} agents per event, sinks: ${Object.keys(SINK_CATALOG).join(', ')}`,
|
|
708
|
+
);
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
function stop() {
|
|
712
|
+
if (_off) { try { _off(); } catch (_) { /* already gone */ } }
|
|
713
|
+
_off = null;
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
module.exports = {
|
|
717
|
+
start,
|
|
718
|
+
stop,
|
|
719
|
+
// Exported for tests + introspection. The pure half (declaredGlobs,
|
|
720
|
+
// matchesTouches, selectFiring, depthRefsFor, sinkNameFor) is the whole
|
|
721
|
+
// selection decision and needs no database, no model and no server to prove.
|
|
722
|
+
fanOut,
|
|
723
|
+
onShipped,
|
|
724
|
+
declaredGlobs,
|
|
725
|
+
matchesTouches,
|
|
726
|
+
selectFiring,
|
|
727
|
+
depthRefsFor,
|
|
728
|
+
isAgentAttributed,
|
|
729
|
+
sinkNameFor,
|
|
730
|
+
resolveSink,
|
|
731
|
+
deliverFinding,
|
|
732
|
+
EVENT,
|
|
733
|
+
SINK_CATALOG,
|
|
734
|
+
DEFAULT_SINK,
|
|
735
|
+
FANOUT_MAX,
|
|
736
|
+
DEFAULT_FANOUT_MAX,
|
|
737
|
+
};
|