@ontrails/core 1.0.0-beta.2 → 1.0.0-beta.21

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