@ontrails/core 0.2.0
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/CHANGELOG.md +849 -0
- package/README.md +190 -0
- package/package.json +36 -0
- package/src/activation-provenance.ts +116 -0
- package/src/activation-source-compatibility.ts +430 -0
- package/src/activation-source-derivation.ts +227 -0
- package/src/activation-source.ts +93 -0
- package/src/blob-ref.ts +90 -0
- package/src/branded.ts +135 -0
- package/src/collections.ts +99 -0
- package/src/compose-batch.ts +69 -0
- package/src/compose-schema.ts +36 -0
- package/src/context.ts +66 -0
- package/src/derive.ts +485 -0
- package/src/detours.ts +8 -0
- package/src/diagnostics.ts +21 -0
- package/src/draft.ts +350 -0
- package/src/entity.ts +346 -0
- package/src/error-rendering.ts +87 -0
- package/src/errors.ts +483 -0
- package/src/execute.ts +1577 -0
- package/src/fetch.ts +138 -0
- package/src/fire.ts +1172 -0
- package/src/glob.ts +81 -0
- package/src/guards.ts +37 -0
- package/src/index.ts +704 -0
- package/src/internal/fork-ctx.ts +69 -0
- package/src/layer-field-rendering.ts +193 -0
- package/src/layer.ts +81 -0
- package/src/observe.ts +361 -0
- package/src/path-scope.ts +66 -0
- package/src/path-security.ts +98 -0
- package/src/patterns/bulk.ts +16 -0
- package/src/patterns/change.ts +12 -0
- package/src/patterns/date-range.ts +12 -0
- package/src/patterns/index.ts +8 -0
- package/src/patterns/pagination.ts +22 -0
- package/src/patterns/progress.ts +13 -0
- package/src/patterns/sorting.ts +14 -0
- package/src/patterns/status.ts +11 -0
- package/src/patterns/timestamps.ts +12 -0
- package/src/permits.ts +12 -0
- package/src/queue.ts +163 -0
- package/src/redaction/index.ts +3 -0
- package/src/redaction/patterns.ts +50 -0
- package/src/redaction/redactor.ts +178 -0
- package/src/resilience.ts +234 -0
- package/src/resource-config.ts +804 -0
- package/src/resource.ts +194 -0
- package/src/result.ts +212 -0
- package/src/run.ts +76 -0
- package/src/runtime-builtins.ts +69 -0
- package/src/schedule-runtime.ts +689 -0
- package/src/schedule.ts +326 -0
- package/src/serialization.ts +265 -0
- package/src/sha256.ts +136 -0
- package/src/signal-diagnostics.ts +633 -0
- package/src/signal-ref.ts +111 -0
- package/src/signal.ts +104 -0
- package/src/store/accessor-protocol.ts +56 -0
- package/src/store/index.ts +4 -0
- package/src/structured-examples.ts +248 -0
- package/src/surface-derivation.ts +91 -0
- package/src/surface-filter.ts +101 -0
- package/src/surface-overlay.ts +694 -0
- package/src/surface-versioning.ts +42 -0
- package/src/topo.ts +835 -0
- package/src/tracing.ts +346 -0
- package/src/trail-id-glob.ts +15 -0
- package/src/trail.ts +1351 -0
- package/src/trails/derive-trail.ts +835 -0
- package/src/trails/index.ts +9 -0
- package/src/trails/ingest.ts +152 -0
- package/src/trails-db.ts +212 -0
- package/src/transport-error-map.ts +163 -0
- package/src/type-utils.ts +87 -0
- package/src/types.ts +300 -0
- package/src/validate-established-topo.ts +73 -0
- package/src/validate-topo.ts +725 -0
- package/src/validation.ts +330 -0
- package/src/version-marker.ts +716 -0
- package/src/version-resolution.ts +308 -0
- package/src/version-runtime.ts +120 -0
- package/src/webhook.ts +461 -0
- package/src/workspace.ts +244 -0
- package/src/zod-wrappers.ts +72 -0
package/src/fire.ts
ADDED
|
@@ -0,0 +1,1172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Signal emission and auto-activation.
|
|
3
|
+
*
|
|
4
|
+
* `createFireFn(topo, producerCtx?, executor, producerTrailId?)` returns a
|
|
5
|
+
* `FireFn` bound to a topo. Calling `fire(signal, payload)` looks up the
|
|
6
|
+
* signal, validates the payload against its schema, finds every trail with
|
|
7
|
+
* the signal in its `on:` array, and invokes each consumer via the supplied
|
|
8
|
+
* executor.
|
|
9
|
+
*
|
|
10
|
+
* The `executor` parameter is an indirection that lets `execute.ts` pass in
|
|
11
|
+
* `executeTrail` without `fire.ts` importing it directly — keeping the two
|
|
12
|
+
* modules dependency-cycle-free.
|
|
13
|
+
*
|
|
14
|
+
* Consumer contexts inherit the producer's full ctx (logger, extensions,
|
|
15
|
+
* resources, abortSignal, requestId, env, workspaceRoot, permit) with
|
|
16
|
+
* `fire` rebound to the same closure so consumers can fan out further.
|
|
17
|
+
* Each consumer gets its own derived context so sibling fan-out branches do
|
|
18
|
+
* not share mutable top-level state. The consumer logger is derived from the
|
|
19
|
+
* producer logger as a child tagged with `signalId` and `consumerId` when
|
|
20
|
+
* `logger.child` exists.
|
|
21
|
+
*
|
|
22
|
+
* Error semantics match the fire-and-forget framing: producer-facing
|
|
23
|
+
* `ctx.fire()` resolves after dispatch is initiated and without a value.
|
|
24
|
+
* Unknown signals, invalid payloads, guard suppression, and consumer errors
|
|
25
|
+
* are logged/diagnosed but do NOT propagate back to the producer. Consumers
|
|
26
|
+
* that need transactional coupling should use `composes:`.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import type { z } from 'zod';
|
|
30
|
+
|
|
31
|
+
import {
|
|
32
|
+
buildActivationProvenanceTraceAttrs,
|
|
33
|
+
getActivationProvenance,
|
|
34
|
+
withActivationProvenance,
|
|
35
|
+
} from './activation-provenance.js';
|
|
36
|
+
import type { ActivationProvenance } from './activation-provenance.js';
|
|
37
|
+
import type { ActivationWhereSpec } from './activation-source.js';
|
|
38
|
+
import { getActivationWherePredicate } from './activation-source.js';
|
|
39
|
+
import { activationSourceKey } from './activation-source-derivation.js';
|
|
40
|
+
import { NotFoundError, TrailsError, ValidationError } from './errors.js';
|
|
41
|
+
import { forkCtx } from './internal/fork-ctx.js';
|
|
42
|
+
import {
|
|
43
|
+
OBSERVE_LOGGER_CONTEXT_KEY,
|
|
44
|
+
OBSERVE_LOGGER_METADATA_KEY,
|
|
45
|
+
} from './observe.js';
|
|
46
|
+
import {
|
|
47
|
+
getTraceContext,
|
|
48
|
+
getTraceSink,
|
|
49
|
+
isTracingDisabled,
|
|
50
|
+
writeActivationTraceRecord,
|
|
51
|
+
writeSignalTraceRecord,
|
|
52
|
+
} from './tracing.js';
|
|
53
|
+
import type { SignalTraceRecordName, TraceSink } from './tracing.js';
|
|
54
|
+
import { Result } from './result.js';
|
|
55
|
+
import type { AnySignal } from './signal.js';
|
|
56
|
+
import {
|
|
57
|
+
createSignalFireSuppressedDiagnostic,
|
|
58
|
+
createSignalHandlerFailedDiagnostic,
|
|
59
|
+
createSignalHandlerRejectedDiagnostic,
|
|
60
|
+
createSignalInvalidDiagnostic,
|
|
61
|
+
createSignalPredicateFailedDiagnostic,
|
|
62
|
+
createSignalUnknownDiagnostic,
|
|
63
|
+
recordSignalDiagnostic,
|
|
64
|
+
signalDiagnosticCauseFromUnknown,
|
|
65
|
+
summarizeSignalPayload,
|
|
66
|
+
} from './signal-diagnostics.js';
|
|
67
|
+
import type {
|
|
68
|
+
SignalDiagnostic,
|
|
69
|
+
SignalDiagnosticSchemaIssue,
|
|
70
|
+
SignalInvalidDiagnostic,
|
|
71
|
+
SignalPayloadSummary,
|
|
72
|
+
} from './signal-diagnostics.js';
|
|
73
|
+
import type { Topo } from './topo.js';
|
|
74
|
+
import type { AnyTrail } from './trail.js';
|
|
75
|
+
import type { FireFn, Logger, TrailContextInit } from './types.js';
|
|
76
|
+
|
|
77
|
+
/** Signature execute.ts passes in to avoid a fire ↔ execute import cycle. */
|
|
78
|
+
export type ConsumerExecutor = (
|
|
79
|
+
consumer: AnyTrail,
|
|
80
|
+
input: unknown,
|
|
81
|
+
ctx: Partial<TrailContextInit>
|
|
82
|
+
) => Promise<Result<unknown, Error>>;
|
|
83
|
+
|
|
84
|
+
type MutableConsumerContext = {
|
|
85
|
+
-readonly [K in keyof Partial<TrailContextInit>]: Partial<TrailContextInit>[K];
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
interface ConsumerActivation {
|
|
89
|
+
readonly trail: AnyTrail;
|
|
90
|
+
readonly wheres: readonly ActivationWhereSpec[];
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const FIRE_STACK_KEY = '__trails_fire_stack';
|
|
94
|
+
const FIRE_PENDING_DISPATCHES_KEY = '__trails_fire_pending_dispatches';
|
|
95
|
+
|
|
96
|
+
const frameworkFireFns = new WeakSet<FireFn>();
|
|
97
|
+
|
|
98
|
+
export const isFrameworkFireFn = (fire: FireFn | undefined): boolean =>
|
|
99
|
+
fire !== undefined && frameworkFireFns.has(fire);
|
|
100
|
+
|
|
101
|
+
const createFireId = (): string =>
|
|
102
|
+
typeof Bun === 'undefined' ? crypto.randomUUID() : Bun.randomUUIDv7();
|
|
103
|
+
|
|
104
|
+
type FireDispatchTracker = Set<Promise<void>>;
|
|
105
|
+
|
|
106
|
+
const getFireDispatchTracker = (
|
|
107
|
+
ctx: Pick<TrailContextInit, 'extensions'> | undefined
|
|
108
|
+
): FireDispatchTracker | undefined => {
|
|
109
|
+
const tracker = ctx?.extensions?.[FIRE_PENDING_DISPATCHES_KEY];
|
|
110
|
+
return tracker instanceof Set ? (tracker as FireDispatchTracker) : undefined;
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
export const withFireDispatchTracking = <T extends TrailContextInit>(
|
|
114
|
+
ctx: T
|
|
115
|
+
): T => {
|
|
116
|
+
if (getFireDispatchTracker(ctx) !== undefined) {
|
|
117
|
+
return ctx;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return {
|
|
121
|
+
...ctx,
|
|
122
|
+
extensions: {
|
|
123
|
+
...ctx.extensions,
|
|
124
|
+
[FIRE_PENDING_DISPATCHES_KEY]: new Set<Promise<void>>(),
|
|
125
|
+
},
|
|
126
|
+
};
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
const trackFireDispatch = (
|
|
130
|
+
ctx: Pick<TrailContextInit, 'extensions'> | undefined,
|
|
131
|
+
dispatch: Promise<void>
|
|
132
|
+
): void => {
|
|
133
|
+
const tracker = getFireDispatchTracker(ctx);
|
|
134
|
+
if (tracker === undefined) {
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
tracker.add(dispatch);
|
|
138
|
+
const untrack = async (): Promise<void> => {
|
|
139
|
+
try {
|
|
140
|
+
await dispatch;
|
|
141
|
+
} finally {
|
|
142
|
+
tracker.delete(dispatch);
|
|
143
|
+
}
|
|
144
|
+
};
|
|
145
|
+
void untrack();
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
export const waitForPendingFireDispatches = async (
|
|
149
|
+
ctx: Pick<TrailContextInit, 'extensions'>
|
|
150
|
+
): Promise<void> => {
|
|
151
|
+
const tracker = getFireDispatchTracker(ctx);
|
|
152
|
+
if (tracker === undefined) {
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
while (tracker.size > 0) {
|
|
157
|
+
await Promise.allSettled(tracker);
|
|
158
|
+
}
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Maximum depth for signal fan-out chains.
|
|
163
|
+
*
|
|
164
|
+
* Cycle detection catches re-entrant fires of the same signal ID (A→B→A),
|
|
165
|
+
* but a chain of distinct signals (A→B→C→D→...) bypasses it. This limit
|
|
166
|
+
* prevents runaway fan-out in pathological topologies.
|
|
167
|
+
*/
|
|
168
|
+
const MAX_FIRE_DEPTH = 16;
|
|
169
|
+
|
|
170
|
+
const getFireStack = (
|
|
171
|
+
ctx: Pick<TrailContextInit, 'extensions'> | undefined
|
|
172
|
+
): readonly string[] => {
|
|
173
|
+
const value = ctx?.extensions?.[FIRE_STACK_KEY];
|
|
174
|
+
return Array.isArray(value) ? (value as readonly string[]) : [];
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
/** Binds a per-consumer `fire` onto a mutable consumer context. */
|
|
178
|
+
type ConsumerFireBinder = (
|
|
179
|
+
consumerCtx: MutableConsumerContext,
|
|
180
|
+
consumerId: string
|
|
181
|
+
) => MutableConsumerContext;
|
|
182
|
+
|
|
183
|
+
const deriveConsumerLogger = (
|
|
184
|
+
producerCtx: TrailContextInit | undefined,
|
|
185
|
+
signalId: string,
|
|
186
|
+
consumerId: string
|
|
187
|
+
): Logger | undefined =>
|
|
188
|
+
producerCtx?.logger?.child?.({ consumerId, signalId }) ?? producerCtx?.logger;
|
|
189
|
+
|
|
190
|
+
const deriveConsumerEnv = (
|
|
191
|
+
producerCtx: TrailContextInit | undefined
|
|
192
|
+
): TrailContextInit['env'] =>
|
|
193
|
+
producerCtx?.env ? { ...producerCtx.env } : undefined;
|
|
194
|
+
|
|
195
|
+
const readExistingObserveMetadata = (
|
|
196
|
+
producerCtx: TrailContextInit | undefined
|
|
197
|
+
): Record<string, unknown> => {
|
|
198
|
+
const value = producerCtx?.extensions?.[OBSERVE_LOGGER_METADATA_KEY];
|
|
199
|
+
return value !== null && typeof value === 'object'
|
|
200
|
+
? (value as Record<string, unknown>)
|
|
201
|
+
: {};
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
const deriveConsumerObserveMetadata = (
|
|
205
|
+
producerCtx: TrailContextInit | undefined,
|
|
206
|
+
signalId: string,
|
|
207
|
+
consumerId: string
|
|
208
|
+
): Record<string, unknown> | undefined => {
|
|
209
|
+
// Only carry observe metadata forward when the producer ctx is using a
|
|
210
|
+
// topo-managed observe logger. Otherwise we'd attach signal fan-out fields
|
|
211
|
+
// to caller-supplied loggers that didn't opt into the structured contract.
|
|
212
|
+
if (producerCtx?.extensions?.[OBSERVE_LOGGER_CONTEXT_KEY] !== true) {
|
|
213
|
+
return undefined;
|
|
214
|
+
}
|
|
215
|
+
return {
|
|
216
|
+
...readExistingObserveMetadata(producerCtx),
|
|
217
|
+
consumerId,
|
|
218
|
+
signalId,
|
|
219
|
+
};
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
const deriveConsumerExtensions = (
|
|
223
|
+
producerCtx: TrailContextInit | undefined,
|
|
224
|
+
signalId: string,
|
|
225
|
+
consumerId: string
|
|
226
|
+
): TrailContextInit['extensions'] => {
|
|
227
|
+
const { [FIRE_PENDING_DISPATCHES_KEY]: _pending, ...extensions } =
|
|
228
|
+
producerCtx?.extensions ?? {};
|
|
229
|
+
const observeMetadata = deriveConsumerObserveMetadata(
|
|
230
|
+
producerCtx,
|
|
231
|
+
signalId,
|
|
232
|
+
consumerId
|
|
233
|
+
);
|
|
234
|
+
return {
|
|
235
|
+
...extensions,
|
|
236
|
+
[FIRE_STACK_KEY]: [...getFireStack(producerCtx), signalId],
|
|
237
|
+
...(observeMetadata === undefined
|
|
238
|
+
? {}
|
|
239
|
+
: { [OBSERVE_LOGGER_METADATA_KEY]: observeMetadata }),
|
|
240
|
+
};
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
const deriveConsumerCtx = (
|
|
244
|
+
producerCtx: TrailContextInit | undefined,
|
|
245
|
+
signalId: string,
|
|
246
|
+
consumerId: string,
|
|
247
|
+
activation: ActivationProvenance
|
|
248
|
+
): MutableConsumerContext =>
|
|
249
|
+
withActivationProvenance(
|
|
250
|
+
producerCtx
|
|
251
|
+
? forkCtx(producerCtx as MutableConsumerContext, {
|
|
252
|
+
env: deriveConsumerEnv(producerCtx),
|
|
253
|
+
extensions: deriveConsumerExtensions(
|
|
254
|
+
producerCtx,
|
|
255
|
+
signalId,
|
|
256
|
+
consumerId
|
|
257
|
+
),
|
|
258
|
+
logger: deriveConsumerLogger(producerCtx, signalId, consumerId),
|
|
259
|
+
})
|
|
260
|
+
: {},
|
|
261
|
+
activation
|
|
262
|
+
);
|
|
263
|
+
|
|
264
|
+
interface FireDiagnosticMetadata {
|
|
265
|
+
readonly activation: ActivationProvenance;
|
|
266
|
+
readonly producerTrailId?: string | undefined;
|
|
267
|
+
readonly runId?: string | undefined;
|
|
268
|
+
readonly traceId?: string | undefined;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
interface SignalTraceAttrsInput {
|
|
272
|
+
readonly consumerIds?: readonly string[] | undefined;
|
|
273
|
+
readonly errorName?: string | undefined;
|
|
274
|
+
readonly handlerTrailId?: string | undefined;
|
|
275
|
+
readonly payload?: SignalPayloadSummary | undefined;
|
|
276
|
+
readonly producerTrailId?: string | undefined;
|
|
277
|
+
readonly runId?: string | undefined;
|
|
278
|
+
readonly schemaIssues?: readonly SignalDiagnosticSchemaIssue[] | undefined;
|
|
279
|
+
readonly signalId: string;
|
|
280
|
+
readonly activation?: ActivationProvenance | undefined;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const deriveFireDiagnosticMetadata = (
|
|
284
|
+
producerCtx: TrailContextInit | undefined,
|
|
285
|
+
producerTrailId: string | undefined,
|
|
286
|
+
signalId: string
|
|
287
|
+
): FireDiagnosticMetadata => {
|
|
288
|
+
const trace = producerCtx ? getTraceContext(producerCtx) : undefined;
|
|
289
|
+
const parent = getActivationProvenance(producerCtx);
|
|
290
|
+
const fireId = createFireId();
|
|
291
|
+
return {
|
|
292
|
+
activation: {
|
|
293
|
+
fireId,
|
|
294
|
+
...(parent?.fireId === undefined ? {} : { parentFireId: parent.fireId }),
|
|
295
|
+
rootFireId: parent?.rootFireId ?? fireId,
|
|
296
|
+
source: {
|
|
297
|
+
id: signalId,
|
|
298
|
+
kind: 'signal',
|
|
299
|
+
...(producerTrailId === undefined ? {} : { producerTrailId }),
|
|
300
|
+
},
|
|
301
|
+
},
|
|
302
|
+
producerTrailId,
|
|
303
|
+
runId: trace?.spanId,
|
|
304
|
+
traceId: trace?.traceId,
|
|
305
|
+
};
|
|
306
|
+
};
|
|
307
|
+
|
|
308
|
+
const recordRuntimeSignalDiagnostic = async (
|
|
309
|
+
producerCtx: TrailContextInit | undefined,
|
|
310
|
+
diagnostic: SignalDiagnostic
|
|
311
|
+
): Promise<boolean> => {
|
|
312
|
+
const record = await recordSignalDiagnostic(producerCtx, diagnostic);
|
|
313
|
+
if (record.promoted) {
|
|
314
|
+
producerCtx?.logger?.warn('Signal diagnostic promoted by strict mode', {
|
|
315
|
+
code: diagnostic.code,
|
|
316
|
+
producerTrailId: diagnostic.producerTrailId,
|
|
317
|
+
runId: diagnostic.runId,
|
|
318
|
+
signalId: diagnostic.signalId,
|
|
319
|
+
traceId: diagnostic.traceId,
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
return record.promoted;
|
|
323
|
+
};
|
|
324
|
+
|
|
325
|
+
const deriveSignalErrorCategory = (error: unknown): string =>
|
|
326
|
+
error instanceof TrailsError ? error.category : 'internal';
|
|
327
|
+
|
|
328
|
+
const signalIssuePathLabel = (issue: SignalDiagnosticSchemaIssue): string => {
|
|
329
|
+
if (issue.path.length === 0) {
|
|
330
|
+
return '$';
|
|
331
|
+
}
|
|
332
|
+
return issue.path.map(String).join('.');
|
|
333
|
+
};
|
|
334
|
+
|
|
335
|
+
const addPayloadSummaryAttrs = (
|
|
336
|
+
attrs: Record<string, unknown>,
|
|
337
|
+
payload: SignalPayloadSummary
|
|
338
|
+
): void => {
|
|
339
|
+
attrs['trails.signal.payload.byte_length'] = payload.byteLength;
|
|
340
|
+
attrs['trails.signal.payload.digest'] = payload.digest;
|
|
341
|
+
attrs['trails.signal.payload.redacted'] = payload.redacted;
|
|
342
|
+
attrs['trails.signal.payload.shape'] = payload.shape;
|
|
343
|
+
if (payload.topLevelEntryCount !== undefined) {
|
|
344
|
+
attrs['trails.signal.payload.top_level_entry_count'] =
|
|
345
|
+
payload.topLevelEntryCount;
|
|
346
|
+
}
|
|
347
|
+
};
|
|
348
|
+
|
|
349
|
+
const buildSignalTraceAttrs = (
|
|
350
|
+
input: SignalTraceAttrsInput
|
|
351
|
+
): Readonly<Record<string, unknown>> => {
|
|
352
|
+
const attrs: Record<string, unknown> = {
|
|
353
|
+
...buildActivationProvenanceTraceAttrs(input.activation),
|
|
354
|
+
'trails.signal.id': input.signalId,
|
|
355
|
+
};
|
|
356
|
+
|
|
357
|
+
if (input.producerTrailId !== undefined) {
|
|
358
|
+
attrs['trails.signal.producer_trail.id'] = input.producerTrailId;
|
|
359
|
+
}
|
|
360
|
+
if (input.runId !== undefined) {
|
|
361
|
+
attrs['trails.signal.run.id'] = input.runId;
|
|
362
|
+
}
|
|
363
|
+
if (input.handlerTrailId !== undefined) {
|
|
364
|
+
attrs['trails.signal.handler_trail.id'] = input.handlerTrailId;
|
|
365
|
+
}
|
|
366
|
+
if (input.consumerIds !== undefined) {
|
|
367
|
+
attrs['trails.signal.consumer_count'] = input.consumerIds.length;
|
|
368
|
+
attrs['trails.signal.consumer_ids'] = input.consumerIds
|
|
369
|
+
.toSorted()
|
|
370
|
+
.join(',');
|
|
371
|
+
}
|
|
372
|
+
if (input.errorName !== undefined) {
|
|
373
|
+
attrs['trails.signal.error.name'] = input.errorName;
|
|
374
|
+
}
|
|
375
|
+
if (input.payload !== undefined) {
|
|
376
|
+
addPayloadSummaryAttrs(attrs, input.payload);
|
|
377
|
+
}
|
|
378
|
+
if (input.schemaIssues !== undefined) {
|
|
379
|
+
const issuePaths = input.schemaIssues.map(signalIssuePathLabel);
|
|
380
|
+
attrs['trails.signal.schema_issue_count'] = input.schemaIssues.length;
|
|
381
|
+
attrs['trails.signal.schema_issue_paths'] = issuePaths.join(',');
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
return attrs;
|
|
385
|
+
};
|
|
386
|
+
|
|
387
|
+
const recordSignalLifecycleTrace = async (
|
|
388
|
+
producerCtx: TrailContextInit | undefined,
|
|
389
|
+
name: SignalTraceRecordName,
|
|
390
|
+
attrs: Readonly<Record<string, unknown>>,
|
|
391
|
+
status?: Parameters<typeof writeSignalTraceRecord>[3],
|
|
392
|
+
errorCategory?: string | undefined,
|
|
393
|
+
sink?: TraceSink | undefined
|
|
394
|
+
): Promise<void> => {
|
|
395
|
+
if (producerCtx === undefined) {
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
await writeSignalTraceRecord(
|
|
399
|
+
producerCtx,
|
|
400
|
+
name,
|
|
401
|
+
attrs,
|
|
402
|
+
status,
|
|
403
|
+
errorCategory,
|
|
404
|
+
sink
|
|
405
|
+
);
|
|
406
|
+
};
|
|
407
|
+
|
|
408
|
+
const recordActivationGuardTrace = async (
|
|
409
|
+
producerCtx: TrailContextInit | undefined,
|
|
410
|
+
diagnosticMetadata: FireDiagnosticMetadata,
|
|
411
|
+
reason: 'cycle' | 'depth',
|
|
412
|
+
signalId: string,
|
|
413
|
+
fireStack: readonly string[],
|
|
414
|
+
limit?: number | undefined,
|
|
415
|
+
sink?: TraceSink | undefined
|
|
416
|
+
): Promise<void> => {
|
|
417
|
+
const attrs: Record<string, unknown> = {
|
|
418
|
+
...buildActivationProvenanceTraceAttrs(diagnosticMetadata.activation),
|
|
419
|
+
'trails.activation.guard.fire_stack': fireStack.join(','),
|
|
420
|
+
'trails.activation.guard.reason': reason,
|
|
421
|
+
'trails.signal.id': signalId,
|
|
422
|
+
};
|
|
423
|
+
if (limit !== undefined) {
|
|
424
|
+
attrs['trails.activation.guard.limit'] = limit;
|
|
425
|
+
}
|
|
426
|
+
await writeActivationTraceRecord(
|
|
427
|
+
'activation.cycle_detected',
|
|
428
|
+
attrs,
|
|
429
|
+
'ok',
|
|
430
|
+
undefined,
|
|
431
|
+
producerCtx === undefined ? undefined : getTraceContext(producerCtx),
|
|
432
|
+
sink
|
|
433
|
+
);
|
|
434
|
+
};
|
|
435
|
+
|
|
436
|
+
const activationEntriesForSignal = (
|
|
437
|
+
trail: AnyTrail,
|
|
438
|
+
signalId: string
|
|
439
|
+
): readonly ConsumerActivation[] => {
|
|
440
|
+
const activations = new Map<string, readonly ActivationWhereSpec[]>();
|
|
441
|
+
for (const activation of trail.activationSources ?? []) {
|
|
442
|
+
if (
|
|
443
|
+
activation.source.kind !== 'signal' ||
|
|
444
|
+
activation.source.id !== signalId
|
|
445
|
+
) {
|
|
446
|
+
continue;
|
|
447
|
+
}
|
|
448
|
+
const key = activationSourceKey(activation.source);
|
|
449
|
+
if (activation.where === undefined) {
|
|
450
|
+
activations.set(key, []);
|
|
451
|
+
continue;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
const wheres = activations.get(key);
|
|
455
|
+
if (wheres === undefined) {
|
|
456
|
+
activations.set(key, [activation.where]);
|
|
457
|
+
} else if (wheres.length > 0) {
|
|
458
|
+
activations.set(key, [...wheres, activation.where]);
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
if (activations.size > 0) {
|
|
462
|
+
return [...activations.values()].map((wheres) => ({
|
|
463
|
+
trail,
|
|
464
|
+
wheres,
|
|
465
|
+
}));
|
|
466
|
+
}
|
|
467
|
+
return trail.on.includes(signalId) ? [{ trail, wheres: [] }] : [];
|
|
468
|
+
};
|
|
469
|
+
|
|
470
|
+
const listConsumerActivations = (
|
|
471
|
+
topo: Topo,
|
|
472
|
+
signalId: string
|
|
473
|
+
): readonly ConsumerActivation[] =>
|
|
474
|
+
topo.list().flatMap((trail) => activationEntriesForSignal(trail, signalId));
|
|
475
|
+
|
|
476
|
+
const consumerTrailsFromActivations = (
|
|
477
|
+
activations: readonly ConsumerActivation[]
|
|
478
|
+
): readonly AnyTrail[] => [
|
|
479
|
+
...new Map(
|
|
480
|
+
activations.map((activation) => [activation.trail.id, activation.trail])
|
|
481
|
+
).values(),
|
|
482
|
+
];
|
|
483
|
+
|
|
484
|
+
const hasActiveSignalTraceContext = (
|
|
485
|
+
producerCtx: TrailContextInit | undefined,
|
|
486
|
+
sink: TraceSink
|
|
487
|
+
): boolean =>
|
|
488
|
+
producerCtx !== undefined &&
|
|
489
|
+
getTraceContext(producerCtx) !== undefined &&
|
|
490
|
+
!isTracingDisabled(sink);
|
|
491
|
+
|
|
492
|
+
const summarizeSignalPayloadForTrace = (
|
|
493
|
+
producerCtx: TrailContextInit | undefined,
|
|
494
|
+
payload: unknown,
|
|
495
|
+
sink: TraceSink
|
|
496
|
+
): SignalPayloadSummary | undefined => {
|
|
497
|
+
if (!hasActiveSignalTraceContext(producerCtx, sink)) {
|
|
498
|
+
return undefined;
|
|
499
|
+
}
|
|
500
|
+
try {
|
|
501
|
+
return summarizeSignalPayload(payload);
|
|
502
|
+
} catch (error) {
|
|
503
|
+
producerCtx?.logger?.debug('Signal payload summary skipped', {
|
|
504
|
+
error: error instanceof Error ? error.message : String(error),
|
|
505
|
+
});
|
|
506
|
+
return undefined;
|
|
507
|
+
}
|
|
508
|
+
};
|
|
509
|
+
|
|
510
|
+
const recordPredicateTrace = async (
|
|
511
|
+
producerCtx: TrailContextInit | undefined,
|
|
512
|
+
name:
|
|
513
|
+
| 'signal.handler.predicate_failed'
|
|
514
|
+
| 'signal.handler.predicate_matched'
|
|
515
|
+
| 'signal.handler.predicate_skipped',
|
|
516
|
+
input: {
|
|
517
|
+
readonly diagnosticMetadata: FireDiagnosticMetadata;
|
|
518
|
+
readonly errorCategory?: string | undefined;
|
|
519
|
+
readonly errorName?: string | undefined;
|
|
520
|
+
readonly handlerTrailId: string;
|
|
521
|
+
readonly payloadSummary?: SignalPayloadSummary | undefined;
|
|
522
|
+
readonly signalId: string;
|
|
523
|
+
readonly status?: Parameters<typeof recordSignalLifecycleTrace>[3];
|
|
524
|
+
readonly traceSink: TraceSink;
|
|
525
|
+
}
|
|
526
|
+
): Promise<void> => {
|
|
527
|
+
await recordSignalLifecycleTrace(
|
|
528
|
+
producerCtx,
|
|
529
|
+
name,
|
|
530
|
+
buildSignalTraceAttrs({
|
|
531
|
+
activation: input.diagnosticMetadata.activation,
|
|
532
|
+
errorName: input.errorName,
|
|
533
|
+
handlerTrailId: input.handlerTrailId,
|
|
534
|
+
payload: input.payloadSummary,
|
|
535
|
+
producerTrailId: input.diagnosticMetadata.producerTrailId,
|
|
536
|
+
runId: input.diagnosticMetadata.runId,
|
|
537
|
+
signalId: input.signalId,
|
|
538
|
+
}),
|
|
539
|
+
input.status,
|
|
540
|
+
input.errorCategory,
|
|
541
|
+
input.traceSink
|
|
542
|
+
);
|
|
543
|
+
};
|
|
544
|
+
|
|
545
|
+
const shouldInvokeConsumer = async (
|
|
546
|
+
activation: ConsumerActivation,
|
|
547
|
+
payload: unknown,
|
|
548
|
+
payloadSummary: SignalPayloadSummary | undefined,
|
|
549
|
+
signalId: string,
|
|
550
|
+
producerCtx: TrailContextInit | undefined,
|
|
551
|
+
diagnosticMetadata: FireDiagnosticMetadata,
|
|
552
|
+
logger: Logger | undefined,
|
|
553
|
+
traceSink: TraceSink
|
|
554
|
+
): Promise<boolean> => {
|
|
555
|
+
if (activation.wheres.length === 0) {
|
|
556
|
+
return true;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
for (const where of activation.wheres) {
|
|
560
|
+
const predicate = getActivationWherePredicate(where);
|
|
561
|
+
if (predicate === undefined) {
|
|
562
|
+
return true;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
try {
|
|
566
|
+
const matched = await predicate(payload);
|
|
567
|
+
await recordPredicateTrace(
|
|
568
|
+
producerCtx,
|
|
569
|
+
matched
|
|
570
|
+
? 'signal.handler.predicate_matched'
|
|
571
|
+
: 'signal.handler.predicate_skipped',
|
|
572
|
+
{
|
|
573
|
+
diagnosticMetadata,
|
|
574
|
+
handlerTrailId: activation.trail.id,
|
|
575
|
+
payloadSummary,
|
|
576
|
+
signalId,
|
|
577
|
+
traceSink,
|
|
578
|
+
}
|
|
579
|
+
);
|
|
580
|
+
if (matched) {
|
|
581
|
+
return true;
|
|
582
|
+
}
|
|
583
|
+
} catch (error) {
|
|
584
|
+
const cause = signalDiagnosticCauseFromUnknown(error);
|
|
585
|
+
const diagnostic = createSignalPredicateFailedDiagnostic({
|
|
586
|
+
...diagnosticMetadata,
|
|
587
|
+
cause: error,
|
|
588
|
+
handlerTrailId: activation.trail.id,
|
|
589
|
+
payload,
|
|
590
|
+
signalId,
|
|
591
|
+
});
|
|
592
|
+
await recordRuntimeSignalDiagnostic(producerCtx, diagnostic);
|
|
593
|
+
await recordPredicateTrace(
|
|
594
|
+
producerCtx,
|
|
595
|
+
'signal.handler.predicate_failed',
|
|
596
|
+
{
|
|
597
|
+
diagnosticMetadata,
|
|
598
|
+
errorCategory: deriveSignalErrorCategory(error),
|
|
599
|
+
errorName: cause.name,
|
|
600
|
+
handlerTrailId: activation.trail.id,
|
|
601
|
+
payloadSummary,
|
|
602
|
+
signalId,
|
|
603
|
+
status: 'err',
|
|
604
|
+
traceSink,
|
|
605
|
+
}
|
|
606
|
+
);
|
|
607
|
+
logger?.warn('Signal activation predicate failed', {
|
|
608
|
+
consumerId: activation.trail.id,
|
|
609
|
+
error: cause.message,
|
|
610
|
+
signalId,
|
|
611
|
+
});
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
return false;
|
|
616
|
+
};
|
|
617
|
+
|
|
618
|
+
/**
|
|
619
|
+
* Fan out a validated signal payload to its consumer trails.
|
|
620
|
+
*
|
|
621
|
+
* @remarks
|
|
622
|
+
* Signal delivery is fire-and-forget notification, not ordered orchestration;
|
|
623
|
+
* if one consumer depends on another, the dependency belongs in `composes:`
|
|
624
|
+
* instead of sibling signal sequencing.
|
|
625
|
+
*
|
|
626
|
+
* `Promise.allSettled` preserves failure isolation for the background
|
|
627
|
+
* completion task. Producer-facing `ctx.fire()` starts this task but does not
|
|
628
|
+
* wait for every consumer to complete before resolving. Each consumer gets its
|
|
629
|
+
* own derived context so sibling fan-out branches do not share mutable
|
|
630
|
+
* top-level state while they overlap. Re-entrant suppression elsewhere in this
|
|
631
|
+
* module is still based on signal-id membership in the current fire stack: it
|
|
632
|
+
* prevents infinite loops, but it can over-suppress legitimate diamond
|
|
633
|
+
* re-fires. Activation provenance records each fire boundary so downstream
|
|
634
|
+
* traces and diagnostics can still reconstruct the chain that activated a
|
|
635
|
+
* consumer.
|
|
636
|
+
*/
|
|
637
|
+
const fanOutToConsumers = async (
|
|
638
|
+
activations: readonly ConsumerActivation[],
|
|
639
|
+
payload: unknown,
|
|
640
|
+
signalId: string,
|
|
641
|
+
producerCtx: TrailContextInit | undefined,
|
|
642
|
+
diagnosticMetadata: FireDiagnosticMetadata,
|
|
643
|
+
bindFire: ConsumerFireBinder,
|
|
644
|
+
executor: ConsumerExecutor,
|
|
645
|
+
logger: Logger | undefined,
|
|
646
|
+
traceSink: TraceSink
|
|
647
|
+
): Promise<void> => {
|
|
648
|
+
const payloadSummary = summarizeSignalPayloadForTrace(
|
|
649
|
+
producerCtx,
|
|
650
|
+
payload,
|
|
651
|
+
traceSink
|
|
652
|
+
);
|
|
653
|
+
const settled = await Promise.allSettled(
|
|
654
|
+
activations.map(async (activation) => {
|
|
655
|
+
const consumer = activation.trail;
|
|
656
|
+
const shouldInvoke = await shouldInvokeConsumer(
|
|
657
|
+
activation,
|
|
658
|
+
payload,
|
|
659
|
+
payloadSummary,
|
|
660
|
+
signalId,
|
|
661
|
+
producerCtx,
|
|
662
|
+
diagnosticMetadata,
|
|
663
|
+
logger,
|
|
664
|
+
traceSink
|
|
665
|
+
);
|
|
666
|
+
if (!shouldInvoke) {
|
|
667
|
+
return consumer.id;
|
|
668
|
+
}
|
|
669
|
+
const consumerCtx = bindFire(
|
|
670
|
+
deriveConsumerCtx(
|
|
671
|
+
producerCtx,
|
|
672
|
+
signalId,
|
|
673
|
+
consumer.id,
|
|
674
|
+
diagnosticMetadata.activation
|
|
675
|
+
),
|
|
676
|
+
consumer.id
|
|
677
|
+
);
|
|
678
|
+
await recordSignalLifecycleTrace(
|
|
679
|
+
producerCtx,
|
|
680
|
+
'signal.handler.invoked',
|
|
681
|
+
buildSignalTraceAttrs({
|
|
682
|
+
activation: diagnosticMetadata.activation,
|
|
683
|
+
handlerTrailId: consumer.id,
|
|
684
|
+
payload: payloadSummary,
|
|
685
|
+
producerTrailId: diagnosticMetadata.producerTrailId,
|
|
686
|
+
runId: diagnosticMetadata.runId,
|
|
687
|
+
signalId,
|
|
688
|
+
}),
|
|
689
|
+
undefined,
|
|
690
|
+
undefined,
|
|
691
|
+
traceSink
|
|
692
|
+
);
|
|
693
|
+
try {
|
|
694
|
+
const consumerResult = await executor(consumer, payload, consumerCtx);
|
|
695
|
+
if (consumerResult.isErr()) {
|
|
696
|
+
const cause = signalDiagnosticCauseFromUnknown(consumerResult.error);
|
|
697
|
+
const diagnostic = createSignalHandlerFailedDiagnostic({
|
|
698
|
+
...diagnosticMetadata,
|
|
699
|
+
cause: consumerResult.error,
|
|
700
|
+
handlerTrailId: consumer.id,
|
|
701
|
+
payload,
|
|
702
|
+
signalId,
|
|
703
|
+
});
|
|
704
|
+
await recordRuntimeSignalDiagnostic(producerCtx, diagnostic);
|
|
705
|
+
await recordSignalLifecycleTrace(
|
|
706
|
+
producerCtx,
|
|
707
|
+
'signal.handler.failed',
|
|
708
|
+
buildSignalTraceAttrs({
|
|
709
|
+
activation: diagnosticMetadata.activation,
|
|
710
|
+
errorName: cause.name,
|
|
711
|
+
handlerTrailId: consumer.id,
|
|
712
|
+
payload: payloadSummary,
|
|
713
|
+
producerTrailId: diagnosticMetadata.producerTrailId,
|
|
714
|
+
runId: diagnosticMetadata.runId,
|
|
715
|
+
signalId,
|
|
716
|
+
}),
|
|
717
|
+
'err',
|
|
718
|
+
deriveSignalErrorCategory(consumerResult.error),
|
|
719
|
+
traceSink
|
|
720
|
+
);
|
|
721
|
+
(consumerCtx.logger ?? logger)?.warn('Signal consumer failed', {
|
|
722
|
+
consumerId: consumer.id,
|
|
723
|
+
error: consumerResult.error.message,
|
|
724
|
+
signalId,
|
|
725
|
+
});
|
|
726
|
+
return consumer.id;
|
|
727
|
+
}
|
|
728
|
+
await recordSignalLifecycleTrace(
|
|
729
|
+
producerCtx,
|
|
730
|
+
'signal.handler.completed',
|
|
731
|
+
buildSignalTraceAttrs({
|
|
732
|
+
activation: diagnosticMetadata.activation,
|
|
733
|
+
handlerTrailId: consumer.id,
|
|
734
|
+
payload: payloadSummary,
|
|
735
|
+
producerTrailId: diagnosticMetadata.producerTrailId,
|
|
736
|
+
runId: diagnosticMetadata.runId,
|
|
737
|
+
signalId,
|
|
738
|
+
}),
|
|
739
|
+
undefined,
|
|
740
|
+
undefined,
|
|
741
|
+
traceSink
|
|
742
|
+
);
|
|
743
|
+
return consumer.id;
|
|
744
|
+
} catch (error) {
|
|
745
|
+
const cause = signalDiagnosticCauseFromUnknown(error);
|
|
746
|
+
const diagnostic = createSignalHandlerRejectedDiagnostic({
|
|
747
|
+
...diagnosticMetadata,
|
|
748
|
+
cause: error,
|
|
749
|
+
handlerTrailId: consumer.id,
|
|
750
|
+
payload,
|
|
751
|
+
signalId,
|
|
752
|
+
});
|
|
753
|
+
await recordRuntimeSignalDiagnostic(producerCtx, diagnostic);
|
|
754
|
+
await recordSignalLifecycleTrace(
|
|
755
|
+
producerCtx,
|
|
756
|
+
'signal.handler.failed',
|
|
757
|
+
buildSignalTraceAttrs({
|
|
758
|
+
activation: diagnosticMetadata.activation,
|
|
759
|
+
errorName: cause.name,
|
|
760
|
+
handlerTrailId: consumer.id,
|
|
761
|
+
payload: payloadSummary,
|
|
762
|
+
producerTrailId: diagnosticMetadata.producerTrailId,
|
|
763
|
+
runId: diagnosticMetadata.runId,
|
|
764
|
+
signalId,
|
|
765
|
+
}),
|
|
766
|
+
'err',
|
|
767
|
+
deriveSignalErrorCategory(error),
|
|
768
|
+
traceSink
|
|
769
|
+
);
|
|
770
|
+
throw error;
|
|
771
|
+
}
|
|
772
|
+
})
|
|
773
|
+
);
|
|
774
|
+
for (const [index, entry] of settled.entries()) {
|
|
775
|
+
if (entry.status !== 'rejected') {
|
|
776
|
+
continue;
|
|
777
|
+
}
|
|
778
|
+
// `executeTrail` normalizes throws into `Result.err`, so reaching this
|
|
779
|
+
// branch means the executor (or the warn call above) rejected
|
|
780
|
+
// unexpectedly. Log at debug to preserve provenance without propagating
|
|
781
|
+
// the failure to the producer (fire-and-forget semantics).
|
|
782
|
+
logger?.debug('Signal consumer rejected unexpectedly', {
|
|
783
|
+
consumerId: activations[index]?.trail.id,
|
|
784
|
+
error:
|
|
785
|
+
entry.reason instanceof Error
|
|
786
|
+
? entry.reason.message
|
|
787
|
+
: String(entry.reason),
|
|
788
|
+
signalId,
|
|
789
|
+
});
|
|
790
|
+
}
|
|
791
|
+
};
|
|
792
|
+
|
|
793
|
+
const recordInvalidSignalDiagnostic = async (
|
|
794
|
+
producerCtx: TrailContextInit | undefined,
|
|
795
|
+
diagnostic: SignalInvalidDiagnostic
|
|
796
|
+
): Promise<boolean> =>
|
|
797
|
+
await recordRuntimeSignalDiagnostic(producerCtx, diagnostic);
|
|
798
|
+
|
|
799
|
+
const createInvalidPayloadError = (
|
|
800
|
+
signalId: string,
|
|
801
|
+
message: string,
|
|
802
|
+
diagnostic: SignalInvalidDiagnostic,
|
|
803
|
+
promoted: boolean
|
|
804
|
+
): ValidationError =>
|
|
805
|
+
new ValidationError(`Invalid payload for signal "${signalId}": ${message}`, {
|
|
806
|
+
context: {
|
|
807
|
+
diagnosticCode: diagnostic.code,
|
|
808
|
+
promoted,
|
|
809
|
+
schemaIssues: diagnostic.schemaIssues,
|
|
810
|
+
signalId,
|
|
811
|
+
},
|
|
812
|
+
});
|
|
813
|
+
|
|
814
|
+
const PAYLOAD_SCHEMA_READ_ERROR_MESSAGE =
|
|
815
|
+
'Payload schema validation could not read the payload safely';
|
|
816
|
+
|
|
817
|
+
type SignalPayloadParseResult =
|
|
818
|
+
| {
|
|
819
|
+
readonly data: unknown;
|
|
820
|
+
readonly success: true;
|
|
821
|
+
}
|
|
822
|
+
| {
|
|
823
|
+
readonly issues: readonly z.core.$ZodIssue[];
|
|
824
|
+
readonly message: string;
|
|
825
|
+
readonly success: false;
|
|
826
|
+
};
|
|
827
|
+
|
|
828
|
+
const unreadablePayloadIssue = (): z.core.$ZodIssue =>
|
|
829
|
+
({
|
|
830
|
+
code: 'custom',
|
|
831
|
+
message: PAYLOAD_SCHEMA_READ_ERROR_MESSAGE,
|
|
832
|
+
path: [],
|
|
833
|
+
}) as z.core.$ZodIssue;
|
|
834
|
+
|
|
835
|
+
const safeParseSignalPayload = (
|
|
836
|
+
signal: AnySignal,
|
|
837
|
+
payload: unknown
|
|
838
|
+
): SignalPayloadParseResult => {
|
|
839
|
+
try {
|
|
840
|
+
const parsed = signal.payload.safeParse(payload);
|
|
841
|
+
if (parsed.success) {
|
|
842
|
+
return { data: parsed.data, success: true };
|
|
843
|
+
}
|
|
844
|
+
return {
|
|
845
|
+
issues: parsed.error.issues,
|
|
846
|
+
message: parsed.error.message,
|
|
847
|
+
success: false,
|
|
848
|
+
};
|
|
849
|
+
} catch {
|
|
850
|
+
return {
|
|
851
|
+
issues: [unreadablePayloadIssue()],
|
|
852
|
+
message: PAYLOAD_SCHEMA_READ_ERROR_MESSAGE,
|
|
853
|
+
success: false,
|
|
854
|
+
};
|
|
855
|
+
}
|
|
856
|
+
};
|
|
857
|
+
|
|
858
|
+
const resolveFireDispatch = async (
|
|
859
|
+
topo: Topo,
|
|
860
|
+
signalId: string,
|
|
861
|
+
payload: unknown,
|
|
862
|
+
producerCtx: TrailContextInit | undefined,
|
|
863
|
+
diagnosticMetadata: FireDiagnosticMetadata,
|
|
864
|
+
traceSink: TraceSink
|
|
865
|
+
): Promise<
|
|
866
|
+
Result<
|
|
867
|
+
{
|
|
868
|
+
readonly activations: readonly ConsumerActivation[];
|
|
869
|
+
readonly consumers: readonly AnyTrail[];
|
|
870
|
+
readonly payload: unknown;
|
|
871
|
+
},
|
|
872
|
+
Error
|
|
873
|
+
>
|
|
874
|
+
> => {
|
|
875
|
+
const signal = topo.signals.get(signalId);
|
|
876
|
+
if (signal === undefined) {
|
|
877
|
+
await recordRuntimeSignalDiagnostic(
|
|
878
|
+
producerCtx,
|
|
879
|
+
createSignalUnknownDiagnostic({
|
|
880
|
+
...diagnosticMetadata,
|
|
881
|
+
signalId,
|
|
882
|
+
})
|
|
883
|
+
);
|
|
884
|
+
return Result.err(
|
|
885
|
+
new NotFoundError(`Signal "${signalId}" not found in topo "${topo.name}"`)
|
|
886
|
+
);
|
|
887
|
+
}
|
|
888
|
+
const parsed = safeParseSignalPayload(signal, payload);
|
|
889
|
+
if (!parsed.success) {
|
|
890
|
+
const diagnostic = createSignalInvalidDiagnostic({
|
|
891
|
+
...diagnosticMetadata,
|
|
892
|
+
payload,
|
|
893
|
+
schemaIssues: parsed.issues,
|
|
894
|
+
signalId,
|
|
895
|
+
});
|
|
896
|
+
const promoted = await recordInvalidSignalDiagnostic(
|
|
897
|
+
producerCtx,
|
|
898
|
+
diagnostic
|
|
899
|
+
);
|
|
900
|
+
await recordSignalLifecycleTrace(
|
|
901
|
+
producerCtx,
|
|
902
|
+
'signal.invalid',
|
|
903
|
+
buildSignalTraceAttrs({
|
|
904
|
+
activation: diagnostic.activation,
|
|
905
|
+
payload: diagnostic.payload,
|
|
906
|
+
producerTrailId: diagnostic.producerTrailId,
|
|
907
|
+
runId: diagnostic.runId,
|
|
908
|
+
schemaIssues: diagnostic.schemaIssues,
|
|
909
|
+
signalId,
|
|
910
|
+
}),
|
|
911
|
+
'err',
|
|
912
|
+
'validation',
|
|
913
|
+
traceSink
|
|
914
|
+
);
|
|
915
|
+
return Result.err(
|
|
916
|
+
createInvalidPayloadError(signalId, parsed.message, diagnostic, promoted)
|
|
917
|
+
);
|
|
918
|
+
}
|
|
919
|
+
const activations = listConsumerActivations(topo, signalId);
|
|
920
|
+
return Result.ok({
|
|
921
|
+
activations,
|
|
922
|
+
consumers: consumerTrailsFromActivations(activations),
|
|
923
|
+
payload: parsed.data,
|
|
924
|
+
});
|
|
925
|
+
};
|
|
926
|
+
|
|
927
|
+
const resolveSignalId = (signalOrId: unknown): Result<string, Error> => {
|
|
928
|
+
if (
|
|
929
|
+
typeof signalOrId === 'object' &&
|
|
930
|
+
signalOrId !== null &&
|
|
931
|
+
'kind' in signalOrId &&
|
|
932
|
+
(signalOrId as { kind: unknown }).kind === 'signal' &&
|
|
933
|
+
'id' in signalOrId &&
|
|
934
|
+
typeof (signalOrId as { id: unknown }).id === 'string'
|
|
935
|
+
) {
|
|
936
|
+
return Result.ok((signalOrId as AnySignal).id);
|
|
937
|
+
}
|
|
938
|
+
if (typeof signalOrId === 'string') {
|
|
939
|
+
return Result.err(
|
|
940
|
+
new ValidationError(
|
|
941
|
+
'ctx.fire() requires a Signal value; string signal ids are not part of the public fire API'
|
|
942
|
+
)
|
|
943
|
+
);
|
|
944
|
+
}
|
|
945
|
+
return Result.err(new ValidationError('ctx.fire() requires a Signal value'));
|
|
946
|
+
};
|
|
947
|
+
|
|
948
|
+
const logFireError = (
|
|
949
|
+
logger: Logger | undefined,
|
|
950
|
+
signalId: string | undefined,
|
|
951
|
+
error: Error
|
|
952
|
+
): void => {
|
|
953
|
+
logger?.warn('Signal fire skipped', {
|
|
954
|
+
error: error.message,
|
|
955
|
+
signalId,
|
|
956
|
+
});
|
|
957
|
+
};
|
|
958
|
+
|
|
959
|
+
/**
|
|
960
|
+
* Build a `FireFn` closure bound to a topo.
|
|
961
|
+
*
|
|
962
|
+
* When `producerCtx` is provided, consumer trails activated via `on:`
|
|
963
|
+
* inherit the producer's logger, extensions, resources, abortSignal,
|
|
964
|
+
* requestId, env, workspaceRoot, and permit. `ctx.fire` on the consumer
|
|
965
|
+
* is rebound to the same closure so consumers can emit downstream
|
|
966
|
+
* signals naturally.
|
|
967
|
+
*/
|
|
968
|
+
export const createFireFn = (
|
|
969
|
+
topo: Topo,
|
|
970
|
+
producerCtx: TrailContextInit | undefined,
|
|
971
|
+
executor: ConsumerExecutor,
|
|
972
|
+
producerTrailId?: string | undefined
|
|
973
|
+
): FireFn => {
|
|
974
|
+
const trackedProducerCtx =
|
|
975
|
+
producerCtx === undefined
|
|
976
|
+
? undefined
|
|
977
|
+
: withFireDispatchTracking(producerCtx);
|
|
978
|
+
const traceSink = topo.observe?.trace ?? getTraceSink();
|
|
979
|
+
const bindConsumerFire: ConsumerFireBinder = (consumerCtx, consumerId) => ({
|
|
980
|
+
...consumerCtx,
|
|
981
|
+
// Pre-bind fire on the consumer ctx as a safety net for direct
|
|
982
|
+
// executeTrail calls that skip the topo-aware path. In the normal
|
|
983
|
+
// fan-out flow below, bindFireToCtx in execute.ts rebinds fire to
|
|
984
|
+
// the fully-traced ctx before the implementation runs, so this assignment
|
|
985
|
+
// is superseded — but keeping it makes consumerCtx self-sufficient
|
|
986
|
+
// for any caller that inspects it pre-execution.
|
|
987
|
+
fire: createFireFn(
|
|
988
|
+
topo,
|
|
989
|
+
consumerCtx as TrailContextInit,
|
|
990
|
+
executor,
|
|
991
|
+
consumerId
|
|
992
|
+
),
|
|
993
|
+
});
|
|
994
|
+
|
|
995
|
+
const dispatchFire = async (
|
|
996
|
+
signalId: string,
|
|
997
|
+
payload: unknown,
|
|
998
|
+
diagnosticMetadata: FireDiagnosticMetadata
|
|
999
|
+
): Promise<Result<void, Error>> => {
|
|
1000
|
+
const dispatch = await resolveFireDispatch(
|
|
1001
|
+
topo,
|
|
1002
|
+
signalId,
|
|
1003
|
+
payload,
|
|
1004
|
+
trackedProducerCtx,
|
|
1005
|
+
diagnosticMetadata,
|
|
1006
|
+
traceSink
|
|
1007
|
+
);
|
|
1008
|
+
if (dispatch.isErr()) {
|
|
1009
|
+
return dispatch;
|
|
1010
|
+
}
|
|
1011
|
+
await recordSignalLifecycleTrace(
|
|
1012
|
+
trackedProducerCtx,
|
|
1013
|
+
'signal.fired',
|
|
1014
|
+
buildSignalTraceAttrs({
|
|
1015
|
+
activation: diagnosticMetadata.activation,
|
|
1016
|
+
consumerIds: dispatch.value.consumers.map((consumer) => consumer.id),
|
|
1017
|
+
payload: summarizeSignalPayloadForTrace(
|
|
1018
|
+
trackedProducerCtx,
|
|
1019
|
+
dispatch.value.payload,
|
|
1020
|
+
traceSink
|
|
1021
|
+
),
|
|
1022
|
+
producerTrailId: diagnosticMetadata.producerTrailId,
|
|
1023
|
+
runId: diagnosticMetadata.runId,
|
|
1024
|
+
signalId,
|
|
1025
|
+
}),
|
|
1026
|
+
undefined,
|
|
1027
|
+
undefined,
|
|
1028
|
+
traceSink
|
|
1029
|
+
);
|
|
1030
|
+
const completion = (async (): Promise<void> => {
|
|
1031
|
+
try {
|
|
1032
|
+
await fanOutToConsumers(
|
|
1033
|
+
dispatch.value.activations,
|
|
1034
|
+
dispatch.value.payload,
|
|
1035
|
+
signalId,
|
|
1036
|
+
trackedProducerCtx,
|
|
1037
|
+
diagnosticMetadata,
|
|
1038
|
+
bindConsumerFire,
|
|
1039
|
+
executor,
|
|
1040
|
+
trackedProducerCtx?.logger,
|
|
1041
|
+
traceSink
|
|
1042
|
+
);
|
|
1043
|
+
} catch (error: unknown) {
|
|
1044
|
+
trackedProducerCtx?.logger?.debug(
|
|
1045
|
+
'Signal dispatch completion failed unexpectedly',
|
|
1046
|
+
{
|
|
1047
|
+
error: error instanceof Error ? error.message : String(error),
|
|
1048
|
+
signalId,
|
|
1049
|
+
}
|
|
1050
|
+
);
|
|
1051
|
+
}
|
|
1052
|
+
})();
|
|
1053
|
+
trackFireDispatch(trackedProducerCtx, completion);
|
|
1054
|
+
return Result.ok();
|
|
1055
|
+
};
|
|
1056
|
+
|
|
1057
|
+
/** Return an early Result if the fire should be suppressed, or null to proceed. */
|
|
1058
|
+
const guardFire = async (
|
|
1059
|
+
signalId: string,
|
|
1060
|
+
stack: readonly string[],
|
|
1061
|
+
diagnosticMetadata: FireDiagnosticMetadata
|
|
1062
|
+
): Promise<Result<void, Error> | null> => {
|
|
1063
|
+
if (stack.length >= MAX_FIRE_DEPTH) {
|
|
1064
|
+
trackedProducerCtx?.logger?.warn(
|
|
1065
|
+
'Signal fan-out depth limit reached — skipping fire',
|
|
1066
|
+
{ depth: stack.length, signalId }
|
|
1067
|
+
);
|
|
1068
|
+
await recordRuntimeSignalDiagnostic(
|
|
1069
|
+
trackedProducerCtx,
|
|
1070
|
+
createSignalFireSuppressedDiagnostic({
|
|
1071
|
+
...diagnosticMetadata,
|
|
1072
|
+
fireStack: [...stack],
|
|
1073
|
+
limit: MAX_FIRE_DEPTH,
|
|
1074
|
+
reason: 'depth',
|
|
1075
|
+
signalId,
|
|
1076
|
+
})
|
|
1077
|
+
);
|
|
1078
|
+
await recordActivationGuardTrace(
|
|
1079
|
+
trackedProducerCtx,
|
|
1080
|
+
diagnosticMetadata,
|
|
1081
|
+
'depth',
|
|
1082
|
+
signalId,
|
|
1083
|
+
stack,
|
|
1084
|
+
MAX_FIRE_DEPTH,
|
|
1085
|
+
traceSink
|
|
1086
|
+
);
|
|
1087
|
+
return Result.ok();
|
|
1088
|
+
}
|
|
1089
|
+
if (stack.includes(signalId)) {
|
|
1090
|
+
trackedProducerCtx?.logger?.debug(
|
|
1091
|
+
'Signal fan-out suppressed due to cycle',
|
|
1092
|
+
{
|
|
1093
|
+
fireStack: [...stack],
|
|
1094
|
+
signalId,
|
|
1095
|
+
}
|
|
1096
|
+
);
|
|
1097
|
+
trackedProducerCtx?.logger?.warn(
|
|
1098
|
+
'Signal cycle detected — skipping re-entrant fire',
|
|
1099
|
+
{ fireStack: [...stack], signalId }
|
|
1100
|
+
);
|
|
1101
|
+
await recordRuntimeSignalDiagnostic(
|
|
1102
|
+
trackedProducerCtx,
|
|
1103
|
+
createSignalFireSuppressedDiagnostic({
|
|
1104
|
+
...diagnosticMetadata,
|
|
1105
|
+
fireStack: [...stack],
|
|
1106
|
+
reason: 'cycle',
|
|
1107
|
+
signalId,
|
|
1108
|
+
})
|
|
1109
|
+
);
|
|
1110
|
+
await recordActivationGuardTrace(
|
|
1111
|
+
trackedProducerCtx,
|
|
1112
|
+
diagnosticMetadata,
|
|
1113
|
+
'cycle',
|
|
1114
|
+
signalId,
|
|
1115
|
+
stack,
|
|
1116
|
+
undefined,
|
|
1117
|
+
traceSink
|
|
1118
|
+
);
|
|
1119
|
+
return Result.ok();
|
|
1120
|
+
}
|
|
1121
|
+
return null;
|
|
1122
|
+
};
|
|
1123
|
+
|
|
1124
|
+
const fireImpl: FireFn = async (
|
|
1125
|
+
signalOrId: unknown,
|
|
1126
|
+
payload: unknown
|
|
1127
|
+
): Promise<void> => {
|
|
1128
|
+
const resolved = resolveSignalId(signalOrId);
|
|
1129
|
+
if (resolved.isErr()) {
|
|
1130
|
+
logFireError(
|
|
1131
|
+
trackedProducerCtx?.logger,
|
|
1132
|
+
typeof signalOrId === 'string' ? signalOrId : undefined,
|
|
1133
|
+
resolved.error
|
|
1134
|
+
);
|
|
1135
|
+
return;
|
|
1136
|
+
}
|
|
1137
|
+
const diagnosticMetadata = deriveFireDiagnosticMetadata(
|
|
1138
|
+
trackedProducerCtx,
|
|
1139
|
+
producerTrailId,
|
|
1140
|
+
resolved.value
|
|
1141
|
+
);
|
|
1142
|
+
const suppressed = await guardFire(
|
|
1143
|
+
resolved.value,
|
|
1144
|
+
getFireStack(trackedProducerCtx),
|
|
1145
|
+
diagnosticMetadata
|
|
1146
|
+
);
|
|
1147
|
+
if (suppressed) {
|
|
1148
|
+
if (suppressed.isErr()) {
|
|
1149
|
+
logFireError(
|
|
1150
|
+
trackedProducerCtx?.logger,
|
|
1151
|
+
resolved.value,
|
|
1152
|
+
suppressed.error
|
|
1153
|
+
);
|
|
1154
|
+
}
|
|
1155
|
+
return;
|
|
1156
|
+
}
|
|
1157
|
+
const dispatched = await dispatchFire(
|
|
1158
|
+
resolved.value,
|
|
1159
|
+
payload,
|
|
1160
|
+
diagnosticMetadata
|
|
1161
|
+
);
|
|
1162
|
+
if (dispatched.isErr()) {
|
|
1163
|
+
logFireError(
|
|
1164
|
+
trackedProducerCtx?.logger,
|
|
1165
|
+
resolved.value,
|
|
1166
|
+
dispatched.error
|
|
1167
|
+
);
|
|
1168
|
+
}
|
|
1169
|
+
};
|
|
1170
|
+
frameworkFireFns.add(fireImpl);
|
|
1171
|
+
return fireImpl;
|
|
1172
|
+
};
|