@ontrails/core 1.0.0-beta.14 → 1.0.0-beta.16
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 +74 -0
- package/README.md +73 -24
- package/package.json +11 -4
- package/src/activation-provenance.ts +111 -0
- package/src/activation-source-compatibility.ts +430 -0
- package/src/activation-source-projection.ts +221 -0
- package/src/activation-source.ts +91 -0
- package/src/blob-ref.ts +51 -0
- package/src/branded.ts +1 -1
- package/src/context.ts +28 -4
- package/src/contour.ts +344 -0
- package/src/cross-batch.ts +69 -0
- package/src/cross-schema.ts +36 -0
- package/src/detours.ts +8 -0
- package/src/draft.ts +60 -52
- package/src/error-projection.ts +51 -0
- package/src/errors.ts +245 -48
- package/src/execute.ts +1250 -40
- package/src/fire.ts +1169 -0
- package/src/index.ts +383 -49
- package/src/internal/fork-ctx.ts +69 -0
- package/src/layer-projection.ts +192 -0
- package/src/layer.ts +81 -0
- package/src/observe.ts +361 -0
- package/src/path-security.ts +1 -1
- package/src/permits.ts +12 -0
- package/src/redaction/patterns.ts +6 -3
- package/src/resilience.ts +11 -11
- package/src/resource-config.ts +792 -0
- package/src/resource.ts +175 -0
- package/src/result.ts +18 -0
- package/src/run.ts +7 -6
- package/src/schedule-runtime.ts +689 -0
- package/src/schedule.ts +324 -0
- package/src/serialization.ts +90 -55
- package/src/signal-diagnostics.ts +633 -0
- package/src/signal-ref.ts +87 -0
- package/src/signal.ts +37 -0
- package/src/store/accessor-protocol.ts +56 -0
- package/src/store/index.ts +4 -0
- package/src/structured-examples.ts +245 -0
- package/src/surface-derivation.ts +91 -0
- package/src/surface-filter.ts +176 -0
- package/src/topo.ts +710 -59
- package/src/tracing.ts +345 -0
- package/src/trail.ts +347 -33
- package/src/trails/derive-trail.ts +842 -0
- package/src/trails/index.ts +9 -0
- package/src/trails/ingest.ts +138 -0
- package/src/{internal/trails-db.ts → trails-db.ts} +50 -31
- package/src/transport-error-map.ts +174 -0
- package/src/type-utils.ts +17 -2
- package/src/types.ts +208 -17
- package/src/validate-established-topo.ts +8 -2
- package/src/validate-topo.ts +244 -13
- package/src/validation.ts +58 -1
- package/src/webhook.ts +276 -0
- package/src/workspace.ts +1 -1
- package/src/zod-wrappers.ts +72 -0
- package/.turbo/turbo-build.log +0 -1
- package/.turbo/turbo-lint.log +0 -3
- package/.turbo/turbo-typecheck.log +0 -1
- package/dist/adapters.d.ts +0 -39
- package/dist/adapters.d.ts.map +0 -1
- package/dist/adapters.js +0 -2
- package/dist/adapters.js.map +0 -1
- package/dist/blob-ref.d.ts +0 -20
- package/dist/blob-ref.d.ts.map +0 -1
- package/dist/blob-ref.js +0 -22
- package/dist/blob-ref.js.map +0 -1
- package/dist/branded.d.ts +0 -36
- package/dist/branded.d.ts.map +0 -1
- package/dist/branded.js +0 -89
- package/dist/branded.js.map +0 -1
- package/dist/collections.d.ts +0 -31
- package/dist/collections.d.ts.map +0 -1
- package/dist/collections.js +0 -60
- package/dist/collections.js.map +0 -1
- package/dist/context.d.ts +0 -10
- package/dist/context.d.ts.map +0 -1
- package/dist/context.js +0 -21
- package/dist/context.js.map +0 -1
- package/dist/derive.d.ts +0 -44
- package/dist/derive.d.ts.map +0 -1
- package/dist/derive.js +0 -149
- package/dist/derive.js.map +0 -1
- package/dist/dispatch.d.ts +0 -27
- package/dist/dispatch.d.ts.map +0 -1
- package/dist/dispatch.js +0 -34
- package/dist/dispatch.js.map +0 -1
- package/dist/draft.d.ts +0 -28
- package/dist/draft.d.ts.map +0 -1
- package/dist/draft.js +0 -156
- package/dist/draft.js.map +0 -1
- package/dist/errors.d.ts +0 -83
- package/dist/errors.d.ts.map +0 -1
- package/dist/errors.js +0 -142
- package/dist/errors.js.map +0 -1
- package/dist/event.d.ts +0 -8
- package/dist/event.d.ts.map +0 -1
- package/dist/event.js +0 -7
- package/dist/event.js.map +0 -1
- package/dist/execute.d.ts +0 -35
- package/dist/execute.d.ts.map +0 -1
- package/dist/execute.js +0 -90
- package/dist/execute.js.map +0 -1
- package/dist/fetch.d.ts +0 -15
- package/dist/fetch.d.ts.map +0 -1
- package/dist/fetch.js +0 -102
- package/dist/fetch.js.map +0 -1
- package/dist/gate.d.ts +0 -17
- package/dist/gate.d.ts.map +0 -1
- package/dist/gate.js +0 -21
- package/dist/gate.js.map +0 -1
- package/dist/guards.d.ts +0 -17
- package/dist/guards.d.ts.map +0 -1
- package/dist/guards.js +0 -25
- package/dist/guards.js.map +0 -1
- package/dist/health.d.ts +0 -18
- package/dist/health.d.ts.map +0 -1
- package/dist/health.js +0 -5
- package/dist/health.js.map +0 -1
- package/dist/index.d.ts +0 -48
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js +0 -51
- package/dist/index.js.map +0 -1
- package/dist/internal/topo-saves.d.ts +0 -47
- package/dist/internal/topo-saves.d.ts.map +0 -1
- package/dist/internal/topo-saves.js +0 -310
- package/dist/internal/topo-saves.js.map +0 -1
- package/dist/internal/topo-store-read.d.ts +0 -67
- package/dist/internal/topo-store-read.d.ts.map +0 -1
- package/dist/internal/topo-store-read.js +0 -222
- package/dist/internal/topo-store-read.js.map +0 -1
- package/dist/internal/topo-store.d.ts +0 -12
- package/dist/internal/topo-store.d.ts.map +0 -1
- package/dist/internal/topo-store.js +0 -571
- package/dist/internal/topo-store.js.map +0 -1
- package/dist/internal/trails-db.d.ts +0 -16
- package/dist/internal/trails-db.d.ts.map +0 -1
- package/dist/internal/trails-db.js +0 -118
- package/dist/internal/trails-db.js.map +0 -1
- package/dist/job.d.ts +0 -24
- package/dist/job.d.ts.map +0 -1
- package/dist/job.js +0 -17
- package/dist/job.js.map +0 -1
- package/dist/layer.d.ts +0 -6
- package/dist/layer.d.ts.map +0 -1
- package/dist/layer.js +0 -4
- package/dist/layer.js.map +0 -1
- package/dist/path-security.d.ts +0 -28
- package/dist/path-security.d.ts.map +0 -1
- package/dist/path-security.js +0 -63
- package/dist/path-security.js.map +0 -1
- package/dist/patterns/bulk.d.ts +0 -15
- package/dist/patterns/bulk.d.ts.map +0 -1
- package/dist/patterns/bulk.js +0 -14
- package/dist/patterns/bulk.js.map +0 -1
- package/dist/patterns/change.d.ts +0 -10
- package/dist/patterns/change.d.ts.map +0 -1
- package/dist/patterns/change.js +0 -10
- package/dist/patterns/change.js.map +0 -1
- package/dist/patterns/date-range.d.ts +0 -10
- package/dist/patterns/date-range.d.ts.map +0 -1
- package/dist/patterns/date-range.js +0 -10
- package/dist/patterns/date-range.js.map +0 -1
- package/dist/patterns/index.d.ts +0 -9
- package/dist/patterns/index.d.ts.map +0 -1
- package/dist/patterns/index.js +0 -9
- package/dist/patterns/index.js.map +0 -1
- package/dist/patterns/pagination.d.ts +0 -18
- package/dist/patterns/pagination.d.ts.map +0 -1
- package/dist/patterns/pagination.js +0 -18
- package/dist/patterns/pagination.js.map +0 -1
- package/dist/patterns/progress.d.ts +0 -11
- package/dist/patterns/progress.d.ts.map +0 -1
- package/dist/patterns/progress.js +0 -11
- package/dist/patterns/progress.js.map +0 -1
- package/dist/patterns/sorting.d.ts +0 -13
- package/dist/patterns/sorting.d.ts.map +0 -1
- package/dist/patterns/sorting.js +0 -10
- package/dist/patterns/sorting.js.map +0 -1
- package/dist/patterns/status.d.ts +0 -15
- package/dist/patterns/status.d.ts.map +0 -1
- package/dist/patterns/status.js +0 -9
- package/dist/patterns/status.js.map +0 -1
- package/dist/patterns/timestamps.d.ts +0 -10
- package/dist/patterns/timestamps.d.ts.map +0 -1
- package/dist/patterns/timestamps.js +0 -10
- package/dist/patterns/timestamps.js.map +0 -1
- package/dist/provision-config.d.ts +0 -22
- package/dist/provision-config.d.ts.map +0 -1
- package/dist/provision-config.js +0 -210
- package/dist/provision-config.js.map +0 -1
- package/dist/provision.d.ts +0 -71
- package/dist/provision.d.ts.map +0 -1
- package/dist/provision.js +0 -56
- package/dist/provision.js.map +0 -1
- package/dist/redaction/index.d.ts +0 -4
- package/dist/redaction/index.d.ts.map +0 -1
- package/dist/redaction/index.js +0 -3
- package/dist/redaction/index.js.map +0 -1
- package/dist/redaction/patterns.d.ts +0 -9
- package/dist/redaction/patterns.d.ts.map +0 -1
- package/dist/redaction/patterns.js +0 -39
- package/dist/redaction/patterns.js.map +0 -1
- package/dist/redaction/redactor.d.ts +0 -27
- package/dist/redaction/redactor.d.ts.map +0 -1
- package/dist/redaction/redactor.js +0 -89
- package/dist/redaction/redactor.js.map +0 -1
- package/dist/resilience.d.ts +0 -34
- package/dist/resilience.d.ts.map +0 -1
- package/dist/resilience.js +0 -164
- package/dist/resilience.js.map +0 -1
- package/dist/result.d.ts +0 -57
- package/dist/result.d.ts.map +0 -1
- package/dist/result.js +0 -156
- package/dist/result.js.map +0 -1
- package/dist/run.d.ts +0 -27
- package/dist/run.d.ts.map +0 -1
- package/dist/run.js +0 -34
- package/dist/run.js.map +0 -1
- package/dist/serialization.d.ts +0 -27
- package/dist/serialization.d.ts.map +0 -1
- package/dist/serialization.js +0 -153
- package/dist/serialization.js.map +0 -1
- package/dist/service-config.d.ts +0 -22
- package/dist/service-config.d.ts.map +0 -1
- package/dist/service-config.js +0 -210
- package/dist/service-config.js.map +0 -1
- package/dist/service.d.ts +0 -71
- package/dist/service.d.ts.map +0 -1
- package/dist/service.js +0 -56
- package/dist/service.js.map +0 -1
- package/dist/signal.d.ts +0 -33
- package/dist/signal.d.ts.map +0 -1
- package/dist/signal.js +0 -17
- package/dist/signal.js.map +0 -1
- package/dist/topo-store.d.ts +0 -48
- package/dist/topo-store.d.ts.map +0 -1
- package/dist/topo-store.js +0 -175
- package/dist/topo-store.js.map +0 -1
- package/dist/topo.d.ts +0 -25
- package/dist/topo.d.ts.map +0 -1
- package/dist/topo.js +0 -112
- package/dist/topo.js.map +0 -1
- package/dist/trail.d.ts +0 -96
- package/dist/trail.d.ts.map +0 -1
- package/dist/trail.js +0 -20
- package/dist/trail.js.map +0 -1
- package/dist/type-utils.d.ts +0 -24
- package/dist/type-utils.d.ts.map +0 -1
- package/dist/type-utils.js +0 -12
- package/dist/type-utils.js.map +0 -1
- package/dist/types.d.ts +0 -71
- package/dist/types.d.ts.map +0 -1
- package/dist/types.js +0 -3
- package/dist/types.js.map +0 -1
- package/dist/validate-established-topo.d.ts +0 -76
- package/dist/validate-established-topo.d.ts.map +0 -1
- package/dist/validate-established-topo.js +0 -43
- package/dist/validate-established-topo.js.map +0 -1
- package/dist/validate-topo.d.ts +0 -24
- package/dist/validate-topo.d.ts.map +0 -1
- package/dist/validate-topo.js +0 -176
- package/dist/validate-topo.js.map +0 -1
- package/dist/validation.d.ts +0 -27
- package/dist/validation.d.ts.map +0 -1
- package/dist/validation.js +0 -165
- package/dist/validation.js.map +0 -1
- package/dist/workspace.d.ts +0 -25
- package/dist/workspace.d.ts.map +0 -1
- package/dist/workspace.js +0 -57
- package/dist/workspace.js.map +0 -1
- package/src/__tests__/blob-ref.test.ts +0 -103
- package/src/__tests__/branded.test.ts +0 -148
- package/src/__tests__/collections.test.ts +0 -126
- package/src/__tests__/context.test.ts +0 -77
- package/src/__tests__/derive.test.ts +0 -260
- package/src/__tests__/errors.test.ts +0 -309
- package/src/__tests__/execute.test.ts +0 -525
- package/src/__tests__/fetch.test.ts +0 -217
- package/src/__tests__/gate.test.ts +0 -125
- package/src/__tests__/guards.test.ts +0 -102
- package/src/__tests__/path-security.test.ts +0 -114
- package/src/__tests__/patterns.test.ts +0 -273
- package/src/__tests__/redaction.test.ts +0 -244
- package/src/__tests__/resilience.test.ts +0 -246
- package/src/__tests__/result.test.ts +0 -155
- package/src/__tests__/run.test.ts +0 -181
- package/src/__tests__/serialization.test.ts +0 -401
- package/src/__tests__/service-config.test.ts +0 -228
- package/src/__tests__/service.test.ts +0 -197
- package/src/__tests__/signal.test.ts +0 -82
- package/src/__tests__/topo-store-read.test.ts +0 -251
- package/src/__tests__/topo-store.test.ts +0 -469
- package/src/__tests__/topo.test.ts +0 -277
- package/src/__tests__/trail-permit.test.ts +0 -60
- package/src/__tests__/trail.test.ts +0 -261
- package/src/__tests__/trails-db.test.ts +0 -191
- package/src/__tests__/type-utils.test.ts +0 -90
- package/src/__tests__/validate-topo.test.ts +0 -489
- package/src/__tests__/validation.test.ts +0 -336
- package/src/__tests__/workspace.test.ts +0 -183
- package/src/event.ts +0 -15
- package/src/gate.ts +0 -44
- package/src/internal/topo-saves.ts +0 -429
- package/src/internal/topo-store-read.ts +0 -473
- package/src/internal/topo-store.ts +0 -1087
- package/src/provision-config.ts +0 -373
- package/src/provision.ts +0 -148
- package/src/topo-store.ts +0 -301
- package/tsconfig.json +0 -9
- package/tsconfig.tsbuildinfo +0 -1
package/src/execute.ts
CHANGED
|
@@ -1,23 +1,77 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Centralized trail execution pipeline.
|
|
3
3
|
*
|
|
4
|
-
* Validates input, builds context, composes
|
|
4
|
+
* Validates input, builds context, composes layers, and runs the
|
|
5
5
|
* implementation. Surfaces (CLI, MCP, HTTP) delegate here instead
|
|
6
6
|
* of reimplementing the pipeline.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
+
import type { z } from 'zod';
|
|
10
|
+
|
|
9
11
|
import type { AnyTrail } from './trail.js';
|
|
10
|
-
import type {
|
|
11
|
-
import type {
|
|
12
|
-
import type {
|
|
12
|
+
import type { Layer } from './layer.js';
|
|
13
|
+
import type { ResourceOverrideMap } from './resource.js';
|
|
14
|
+
import type { TraceContext, TraceRecord } from './tracing.js';
|
|
15
|
+
import type { Topo } from './topo.js';
|
|
16
|
+
|
|
17
|
+
import {
|
|
18
|
+
buildActivationProvenanceTraceAttrs,
|
|
19
|
+
getActivationProvenance,
|
|
20
|
+
} from './activation-provenance.js';
|
|
21
|
+
import {
|
|
22
|
+
createFireFn,
|
|
23
|
+
isFrameworkFireFn,
|
|
24
|
+
waitForPendingFireDispatches,
|
|
25
|
+
withFireDispatchTracking,
|
|
26
|
+
} from './fire.js';
|
|
27
|
+
import type { BasePermit } from './permits.js';
|
|
28
|
+
import type {
|
|
29
|
+
CrossBatchOptions,
|
|
30
|
+
CrossFn,
|
|
31
|
+
Detour,
|
|
32
|
+
Implementation,
|
|
33
|
+
TraceFn,
|
|
34
|
+
TrailContext,
|
|
35
|
+
TrailContextInit,
|
|
36
|
+
} from './types.js';
|
|
13
37
|
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
16
|
-
import {
|
|
38
|
+
import { createTrailContext, passthroughTrace } from './context.js';
|
|
39
|
+
import { buildCrossValidationSchema } from './cross-schema.js';
|
|
40
|
+
import {
|
|
41
|
+
CancelledError,
|
|
42
|
+
InternalError,
|
|
43
|
+
NotFoundError,
|
|
44
|
+
PermitError,
|
|
45
|
+
RetryExhaustedError,
|
|
46
|
+
TrailsError,
|
|
47
|
+
ValidationError,
|
|
48
|
+
} from './errors.js';
|
|
49
|
+
import {
|
|
50
|
+
claimNextCrossBatchIndex,
|
|
51
|
+
createCrossBatchValidationResults,
|
|
52
|
+
normalizeCrossBatchConcurrency,
|
|
53
|
+
} from './cross-batch.js';
|
|
54
|
+
import { forkCtx } from './internal/fork-ctx.js';
|
|
55
|
+
import {
|
|
56
|
+
TRACE_CONTEXT_KEY,
|
|
57
|
+
completeRecord,
|
|
58
|
+
createSpanRecord,
|
|
59
|
+
createTraceRecord,
|
|
60
|
+
getTraceSink,
|
|
61
|
+
isTracingDisabled,
|
|
62
|
+
writeToSink,
|
|
63
|
+
} from './tracing.js';
|
|
64
|
+
import {
|
|
65
|
+
OBSERVE_LOGGER_CONTEXT_KEY,
|
|
66
|
+
OBSERVE_LOGGER_METADATA_KEY,
|
|
67
|
+
createObserveLogger,
|
|
68
|
+
} from './observe.js';
|
|
17
69
|
import { Result } from './result.js';
|
|
18
|
-
import {
|
|
19
|
-
import {
|
|
20
|
-
import {
|
|
70
|
+
import { DETOUR_MAX_ATTEMPTS_CAP } from './detours.js';
|
|
71
|
+
import { createResourceLookup } from './resource.js';
|
|
72
|
+
import { createResources } from './resource-config.js';
|
|
73
|
+
import { LAYER_INPUTS_KEY, SURFACE_KEY } from './types.js';
|
|
74
|
+
import { validateInput, validateOutput } from './validation.js';
|
|
21
75
|
|
|
22
76
|
type MutableTrailContext = {
|
|
23
77
|
-readonly [K in keyof TrailContext]: TrailContext[K];
|
|
@@ -33,18 +87,85 @@ export interface ExecuteTrailOptions {
|
|
|
33
87
|
readonly ctx?: Partial<TrailContextInit> | undefined;
|
|
34
88
|
/** AbortSignal override (takes final precedence over ctx and factory). */
|
|
35
89
|
readonly abortSignal?: AbortSignal | undefined;
|
|
36
|
-
/**
|
|
37
|
-
|
|
90
|
+
/**
|
|
91
|
+
* Typed layers supplied for this execution.
|
|
92
|
+
*
|
|
93
|
+
* Layers compose around the trail implementation. Layers without `input`
|
|
94
|
+
* schemas are surface-invisible wrappers for concerns such as tenant guards,
|
|
95
|
+
* rate limiting, circuit breaking, or custom audit logging.
|
|
96
|
+
*/
|
|
97
|
+
readonly layers?: readonly Layer[] | undefined;
|
|
98
|
+
/**
|
|
99
|
+
* Typed layers attached at surface scope.
|
|
100
|
+
*
|
|
101
|
+
* Surfaces (CLI, MCP, HTTP) forward their `layers` option here so they
|
|
102
|
+
* compose around every trail dispatched through that surface. The final
|
|
103
|
+
* composition order is `topo → surface → trail → blaze` (outermost-first).
|
|
104
|
+
*/
|
|
105
|
+
readonly surfaceLayers?: readonly Layer[] | undefined;
|
|
106
|
+
/**
|
|
107
|
+
* Typed layers attached at topo scope.
|
|
108
|
+
*
|
|
109
|
+
* The CLI/MCP/HTTP surfaces typically forward `topo.layers` here so the
|
|
110
|
+
* topo's declared layers wrap every trail invocation. The final
|
|
111
|
+
* composition order is `topo → surface → trail → blaze` (outermost-first).
|
|
112
|
+
*/
|
|
113
|
+
readonly topoLayers?: readonly Layer[] | undefined;
|
|
38
114
|
/** Factory that produces a base TrailContext (takes precedence over defaults). */
|
|
39
115
|
readonly createContext?:
|
|
40
116
|
| (() => TrailContextInit | Promise<TrailContextInit>)
|
|
41
117
|
| undefined;
|
|
42
|
-
/** Explicit
|
|
43
|
-
readonly
|
|
44
|
-
/** Config values for
|
|
118
|
+
/** Explicit resource instance overrides keyed by resource ID. */
|
|
119
|
+
readonly resources?: ResourceOverrideMap | undefined;
|
|
120
|
+
/** Config values for resources that declare a `config` schema, keyed by resource ID. */
|
|
45
121
|
readonly configValues?:
|
|
46
122
|
| Readonly<Record<string, Record<string, unknown>>>
|
|
47
123
|
| undefined;
|
|
124
|
+
/** Topo used for signal-driven activation; required for `ctx.fire()` to work. */
|
|
125
|
+
readonly topo?: Topo | undefined;
|
|
126
|
+
/**
|
|
127
|
+
* Whether this invocation is a dry run.
|
|
128
|
+
*
|
|
129
|
+
* Sets `ctx.dryRun` for the trail. Defaults to `false`. The framework
|
|
130
|
+
* never short-circuits execution on this field — it only carries the
|
|
131
|
+
* flag through. Trails that read `ctx.dryRun` decide what dry-run means.
|
|
132
|
+
*/
|
|
133
|
+
readonly dryRun?: boolean | undefined;
|
|
134
|
+
/**
|
|
135
|
+
* Permit to overlay onto `ctx.permit` for this invocation.
|
|
136
|
+
*
|
|
137
|
+
* When provided, this overrides any permit supplied via `ctx.permit` on
|
|
138
|
+
* the partial context overrides or via the `createContext` factory. Leave
|
|
139
|
+
* unset to inherit the permit from the resolved context (typically
|
|
140
|
+
* `undefined`). Surfaces parse and validate the permit at their boundary
|
|
141
|
+
* (e.g. CLI `--permit '<json>'`) before passing it here.
|
|
142
|
+
*/
|
|
143
|
+
readonly permit?: BasePermit | undefined;
|
|
144
|
+
/**
|
|
145
|
+
* Per-layer runtime input keyed by `Layer.name`.
|
|
146
|
+
*
|
|
147
|
+
* Surfaces (CLI, MCP, HTTP) parse their native idiom into a per-layer
|
|
148
|
+
* input object — usually projected from each layer's `input` schema —
|
|
149
|
+
* and pass it here. The executor merges these into
|
|
150
|
+
* `ctx.extensions[LAYER_INPUTS_KEY]` so layers can read their own slot
|
|
151
|
+
* via `ctx.extensions?.[LAYER_INPUTS_KEY]?.[layer.name]`.
|
|
152
|
+
*
|
|
153
|
+
* @see TRL-473 for the CLI projection contract.
|
|
154
|
+
*/
|
|
155
|
+
readonly layerInputs?: Readonly<Record<string, unknown>> | undefined;
|
|
156
|
+
/**
|
|
157
|
+
* Override the validation schema used for input validation.
|
|
158
|
+
*
|
|
159
|
+
* When a trail is invoked via `ctx.cross()` and the target declares
|
|
160
|
+
* `crossInput`, the cross function merges `trail.input` with
|
|
161
|
+
* `trail.crossInput` and passes the merged schema here so validation
|
|
162
|
+
* accepts both public and composition-only fields.
|
|
163
|
+
*
|
|
164
|
+
* Used by the cross execution path; not part of the public API.
|
|
165
|
+
*
|
|
166
|
+
* @internal
|
|
167
|
+
*/
|
|
168
|
+
readonly validationSchema?: z.ZodType | undefined;
|
|
48
169
|
}
|
|
49
170
|
|
|
50
171
|
// ---------------------------------------------------------------------------
|
|
@@ -63,25 +184,55 @@ const applyContextOverrides = (
|
|
|
63
184
|
}
|
|
64
185
|
: base;
|
|
65
186
|
|
|
66
|
-
|
|
187
|
+
const withAbort = options?.abortSignal
|
|
67
188
|
? { ...withOverrides, abortSignal: options.abortSignal }
|
|
68
189
|
: withOverrides;
|
|
190
|
+
|
|
191
|
+
const withDryRun =
|
|
192
|
+
options?.dryRun === undefined
|
|
193
|
+
? withAbort
|
|
194
|
+
: { ...withAbort, dryRun: options.dryRun };
|
|
195
|
+
|
|
196
|
+
const withPermit =
|
|
197
|
+
options?.permit === undefined
|
|
198
|
+
? withDryRun
|
|
199
|
+
: { ...withDryRun, permit: options.permit };
|
|
200
|
+
|
|
201
|
+
if (options?.layerInputs === undefined) {
|
|
202
|
+
return withPermit;
|
|
203
|
+
}
|
|
204
|
+
// Merge per-layer inputs onto any inherited LAYER_INPUTS_KEY slot so
|
|
205
|
+
// crossed/forked contexts can preserve outer-surface routing.
|
|
206
|
+
const inheritedExtensions = withPermit.extensions ?? {};
|
|
207
|
+
const inheritedLayerInputs = (inheritedExtensions[LAYER_INPUTS_KEY] ?? {}) as
|
|
208
|
+
| Readonly<Record<string, unknown>>
|
|
209
|
+
| Record<string, unknown>;
|
|
210
|
+
return {
|
|
211
|
+
...withPermit,
|
|
212
|
+
extensions: {
|
|
213
|
+
...inheritedExtensions,
|
|
214
|
+
[LAYER_INPUTS_KEY]: {
|
|
215
|
+
...inheritedLayerInputs,
|
|
216
|
+
...options.layerInputs,
|
|
217
|
+
},
|
|
218
|
+
},
|
|
219
|
+
};
|
|
69
220
|
};
|
|
70
221
|
|
|
71
|
-
const
|
|
222
|
+
const bindResourceLookup = (
|
|
72
223
|
resolved: TrailContextInit,
|
|
73
224
|
options?: ExecuteTrailOptions
|
|
74
225
|
): TrailContext => {
|
|
75
226
|
if (
|
|
76
227
|
options?.ctx?.extensions === undefined &&
|
|
77
|
-
resolved.
|
|
228
|
+
resolved.resource !== undefined
|
|
78
229
|
) {
|
|
79
230
|
return resolved as TrailContext;
|
|
80
231
|
}
|
|
81
232
|
|
|
82
233
|
const bound = { ...resolved } as MutableTrailContext;
|
|
83
|
-
const lookup =
|
|
84
|
-
bound.
|
|
234
|
+
const lookup = createResourceLookup(() => bound);
|
|
235
|
+
bound.resource = lookup;
|
|
85
236
|
return bound;
|
|
86
237
|
};
|
|
87
238
|
|
|
@@ -92,39 +243,985 @@ const bindProvisionLookup = (
|
|
|
92
243
|
* 1. Factory (`createContext`) or `createTrailContext()` defaults.
|
|
93
244
|
* 2. Partial `ctx` overrides merged on top.
|
|
94
245
|
* 3. `abortSignal` override takes final precedence.
|
|
246
|
+
* 4. `dryRun` option takes final precedence (defaults to `false` via
|
|
247
|
+
* `createTrailContext` when neither option nor `ctx.dryRun` is provided).
|
|
248
|
+
* 5. `permit` option takes final precedence over any inherited permit when
|
|
249
|
+
* provided; leaving it unset preserves whatever the resolved context
|
|
250
|
+
* already carries.
|
|
95
251
|
*/
|
|
96
252
|
const resolveContext = async (
|
|
97
253
|
options?: ExecuteTrailOptions
|
|
98
254
|
): Promise<TrailContext> => {
|
|
99
255
|
const seed = options?.createContext
|
|
100
256
|
? await options.createContext()
|
|
101
|
-
:
|
|
102
|
-
const base =
|
|
257
|
+
: undefined;
|
|
258
|
+
const base = createTrailContext(seed);
|
|
103
259
|
const resolved = applyContextOverrides(base, options);
|
|
104
|
-
return
|
|
260
|
+
return bindResourceLookup(resolved, options);
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
const readObserveLoggerMetadata = (
|
|
264
|
+
ctx: TrailContext
|
|
265
|
+
): Record<string, unknown> => {
|
|
266
|
+
const value = ctx.extensions?.[OBSERVE_LOGGER_METADATA_KEY];
|
|
267
|
+
return value !== null && typeof value === 'object'
|
|
268
|
+
? (value as Record<string, unknown>)
|
|
269
|
+
: {};
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
const applyTopoObserveContext = (
|
|
273
|
+
trail: AnyTrail,
|
|
274
|
+
ctx: TrailContext,
|
|
275
|
+
topo: Topo | undefined
|
|
276
|
+
): TrailContext => {
|
|
277
|
+
if (topo?.observe?.log === undefined) {
|
|
278
|
+
return ctx;
|
|
279
|
+
}
|
|
280
|
+
const hasTopoObserveLogger =
|
|
281
|
+
ctx.extensions?.[OBSERVE_LOGGER_CONTEXT_KEY] === true;
|
|
282
|
+
if (ctx.logger !== undefined && !hasTopoObserveLogger) {
|
|
283
|
+
return ctx;
|
|
284
|
+
}
|
|
285
|
+
const { log } = topo.observe;
|
|
286
|
+
// Accumulated metadata (e.g. signal fan-out fields) takes precedence over
|
|
287
|
+
// the previous observe logger's `topo`/`trailId` values, but the new trail's
|
|
288
|
+
// identity wins so log records keep pointing at the trail currently running.
|
|
289
|
+
const accumulated = readObserveLoggerMetadata(ctx);
|
|
290
|
+
return {
|
|
291
|
+
...ctx,
|
|
292
|
+
extensions: {
|
|
293
|
+
...ctx.extensions,
|
|
294
|
+
[OBSERVE_LOGGER_CONTEXT_KEY]: true,
|
|
295
|
+
},
|
|
296
|
+
logger: createObserveLogger(log, trail.id, {
|
|
297
|
+
...accumulated,
|
|
298
|
+
topo: topo.name,
|
|
299
|
+
trailId: trail.id,
|
|
300
|
+
}),
|
|
301
|
+
};
|
|
302
|
+
};
|
|
303
|
+
|
|
304
|
+
const findMissingScopes = (
|
|
305
|
+
required: readonly string[],
|
|
306
|
+
held: readonly string[]
|
|
307
|
+
): readonly string[] => required.filter((scope) => !held.includes(scope));
|
|
308
|
+
|
|
309
|
+
const enforcePermitRequirement = (
|
|
310
|
+
trail: AnyTrail,
|
|
311
|
+
ctx: TrailContext
|
|
312
|
+
): Result<TrailContext, Error> => {
|
|
313
|
+
const requirement = trail.permit;
|
|
314
|
+
if (requirement === undefined || requirement === 'public') {
|
|
315
|
+
return Result.ok(ctx);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
if (ctx.permit === undefined) {
|
|
319
|
+
return Result.err(
|
|
320
|
+
new PermitError('No permit provided', {
|
|
321
|
+
context: { required: requirement.scopes, trailId: trail.id },
|
|
322
|
+
})
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
const missing = findMissingScopes(requirement.scopes, ctx.permit.scopes);
|
|
327
|
+
return missing.length === 0
|
|
328
|
+
? Result.ok(ctx)
|
|
329
|
+
: Result.err(
|
|
330
|
+
new PermitError(`Missing scopes: ${missing.join(', ')}`, {
|
|
331
|
+
context: { missing, required: requirement.scopes, trailId: trail.id },
|
|
332
|
+
})
|
|
333
|
+
);
|
|
105
334
|
};
|
|
106
335
|
|
|
107
336
|
const prepareContext = async (
|
|
108
337
|
trail: AnyTrail,
|
|
109
338
|
options?: ExecuteTrailOptions
|
|
110
|
-
): Promise<
|
|
111
|
-
|
|
112
|
-
|
|
339
|
+
): Promise<
|
|
340
|
+
Result<
|
|
341
|
+
{ readonly ctx: TrailContext; readonly releaseResources: () => void },
|
|
342
|
+
Error
|
|
343
|
+
>
|
|
344
|
+
> => {
|
|
345
|
+
const baseCtx = applyTopoObserveContext(
|
|
113
346
|
trail,
|
|
114
|
-
|
|
115
|
-
options?.
|
|
347
|
+
await resolveContext(options),
|
|
348
|
+
options?.topo
|
|
349
|
+
);
|
|
350
|
+
const permitted = enforcePermitRequirement(trail, baseCtx);
|
|
351
|
+
if (permitted.isErr()) {
|
|
352
|
+
return Result.err(permitted.error);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const resources = await createResources(
|
|
356
|
+
trail,
|
|
357
|
+
permitted.value,
|
|
358
|
+
options?.resources,
|
|
116
359
|
options?.configValues
|
|
117
360
|
);
|
|
361
|
+
return resources.isErr()
|
|
362
|
+
? Result.err(resources.error)
|
|
363
|
+
: Result.ok({
|
|
364
|
+
ctx: resources.value.ctx,
|
|
365
|
+
releaseResources: resources.value.release,
|
|
366
|
+
});
|
|
367
|
+
};
|
|
368
|
+
|
|
369
|
+
// ---------------------------------------------------------------------------
|
|
370
|
+
// Intrinsic tracing
|
|
371
|
+
// ---------------------------------------------------------------------------
|
|
372
|
+
|
|
373
|
+
/** Derive the status + error category fields from a trail result. */
|
|
374
|
+
const deriveResultErrorCategory = (error: Error): string => {
|
|
375
|
+
if (error instanceof TrailsError) {
|
|
376
|
+
return error.category;
|
|
377
|
+
}
|
|
378
|
+
return 'internal';
|
|
379
|
+
};
|
|
380
|
+
|
|
381
|
+
const deriveOutcome = (
|
|
382
|
+
result: Result<unknown, Error>
|
|
383
|
+
): {
|
|
384
|
+
readonly status: TraceRecord['status'];
|
|
385
|
+
readonly errorCategory: string | undefined;
|
|
386
|
+
} =>
|
|
387
|
+
result.match<{
|
|
388
|
+
readonly status: TraceRecord['status'];
|
|
389
|
+
readonly errorCategory: string | undefined;
|
|
390
|
+
}>({
|
|
391
|
+
err: (error) => ({
|
|
392
|
+
errorCategory: deriveResultErrorCategory(error),
|
|
393
|
+
status: error instanceof CancelledError ? 'cancelled' : 'err',
|
|
394
|
+
}),
|
|
395
|
+
ok: () => ({ errorCategory: undefined, status: 'ok' }),
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* Best-effort error category for a thrown (not Result.err) value.
|
|
400
|
+
*
|
|
401
|
+
* Unknown/non-Error throws normalize to `'internal'` so the trace record
|
|
402
|
+
* always carries a category when the trail unexpectedly throws.
|
|
403
|
+
*/
|
|
404
|
+
const categorizeSpanError = (error: unknown): string => {
|
|
405
|
+
if (error instanceof TrailsError) {
|
|
406
|
+
return error.category;
|
|
407
|
+
}
|
|
408
|
+
return 'internal';
|
|
409
|
+
};
|
|
410
|
+
|
|
411
|
+
/** Extract the permit identity fields for the trace record. */
|
|
412
|
+
const extractPermit = (
|
|
413
|
+
ctx: TrailContext
|
|
414
|
+
): { readonly id: string; readonly tenantId?: string } | undefined => {
|
|
415
|
+
if (ctx.permit === undefined) {
|
|
416
|
+
return undefined;
|
|
417
|
+
}
|
|
418
|
+
const tenantId =
|
|
419
|
+
'tenantId' in ctx.permit
|
|
420
|
+
? (ctx.permit as { tenantId?: string }).tenantId
|
|
421
|
+
: undefined;
|
|
422
|
+
return tenantId === undefined
|
|
423
|
+
? { id: ctx.permit.id }
|
|
424
|
+
: { id: ctx.permit.id, tenantId };
|
|
425
|
+
};
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* Build a `ctx.trace` function bound to a parent trace context.
|
|
429
|
+
*
|
|
430
|
+
* Each call creates a child span under the parent, times the callback,
|
|
431
|
+
* records success/failure with the appropriate error category, writes the
|
|
432
|
+
* completed span to the sink, and returns the callback result. Errors
|
|
433
|
+
* thrown by the callback are recorded and then rethrown.
|
|
434
|
+
*
|
|
435
|
+
* The returned function reads the *current* trace context from its captured
|
|
436
|
+
* parent. That means direct nesting (`ctx.trace('a', () => ctx.trace('b',
|
|
437
|
+
* ...))`) produces siblings under `a`'s parent, not children of `a`. For
|
|
438
|
+
* true child nesting, callers should cross into another trail (which gets
|
|
439
|
+
* its own root record parented by this one) — full cross-trail parenting
|
|
440
|
+
* is implemented in a later phase. For Phase 1, sibling spans under the
|
|
441
|
+
* trail's root are the supported shape.
|
|
442
|
+
*/
|
|
443
|
+
const buildTraceFn =
|
|
444
|
+
(parent: TraceContext, sink: ReturnType<typeof getTraceSink>): TraceFn =>
|
|
445
|
+
async <T>(label: string, fn: () => T | Promise<T>): Promise<T> => {
|
|
446
|
+
const record = createSpanRecord(parent, label);
|
|
447
|
+
try {
|
|
448
|
+
const value = await fn();
|
|
449
|
+
await writeToSink(sink, completeRecord(record, 'ok'));
|
|
450
|
+
return value;
|
|
451
|
+
} catch (error: unknown) {
|
|
452
|
+
const errorCategory = categorizeSpanError(error);
|
|
453
|
+
const status: TraceRecord['status'] =
|
|
454
|
+
error instanceof CancelledError ? 'cancelled' : 'err';
|
|
455
|
+
await writeToSink(sink, completeRecord(record, status, errorCategory));
|
|
456
|
+
throw error;
|
|
457
|
+
}
|
|
458
|
+
};
|
|
459
|
+
|
|
460
|
+
/** Build the root trace record + trace-enriched context for a trail run. */
|
|
461
|
+
const buildTracedContext = (
|
|
462
|
+
trail: AnyTrail,
|
|
463
|
+
ctx: TrailContext,
|
|
464
|
+
sink: ReturnType<typeof getTraceSink>
|
|
465
|
+
): { readonly record: TraceRecord; readonly tracedCtx: TrailContext } => {
|
|
466
|
+
// If a parent trace context is present (set by an outer executeTrail when
|
|
467
|
+
// the current trail was invoked via ctx.cross or ctx.fire), inherit its
|
|
468
|
+
// traceId/rootId so the trace tree spans trail boundaries. Otherwise this
|
|
469
|
+
// execution becomes a fresh root.
|
|
470
|
+
const parent = ctx.extensions?.[TRACE_CONTEXT_KEY] as
|
|
471
|
+
| TraceContext
|
|
472
|
+
| undefined;
|
|
473
|
+
|
|
474
|
+
const record = createTraceRecord({
|
|
475
|
+
intent: trail.intent,
|
|
476
|
+
parentId: parent?.spanId,
|
|
477
|
+
permit: extractPermit(ctx),
|
|
478
|
+
rootId: parent?.rootId,
|
|
479
|
+
sampled: parent?.sampled ?? true,
|
|
480
|
+
surface: ctx.extensions?.[SURFACE_KEY] as TraceRecord['surface'],
|
|
481
|
+
traceId: parent?.traceId,
|
|
482
|
+
trailId: trail.id,
|
|
483
|
+
});
|
|
484
|
+
const activation = getActivationProvenance(ctx);
|
|
485
|
+
const recordWithAttrs: TraceRecord =
|
|
486
|
+
activation === undefined
|
|
487
|
+
? record
|
|
488
|
+
: {
|
|
489
|
+
...record,
|
|
490
|
+
attrs: buildActivationProvenanceTraceAttrs(activation),
|
|
491
|
+
};
|
|
492
|
+
|
|
493
|
+
// Root trace context for this trail's span. When inheriting a parent, the
|
|
494
|
+
// traceId/rootId carry forward and only spanId advances to the new record.
|
|
495
|
+
const rootTrace: TraceContext = {
|
|
496
|
+
rootId: parent?.rootId ?? record.id,
|
|
497
|
+
sampled: recordWithAttrs.sampled ?? true,
|
|
498
|
+
spanId: recordWithAttrs.id,
|
|
499
|
+
traceId: recordWithAttrs.traceId,
|
|
500
|
+
};
|
|
501
|
+
|
|
502
|
+
const tracedCtx: TrailContext = {
|
|
503
|
+
...ctx,
|
|
504
|
+
extensions: {
|
|
505
|
+
...ctx.extensions,
|
|
506
|
+
[TRACE_CONTEXT_KEY]: rootTrace,
|
|
507
|
+
},
|
|
508
|
+
trace: buildTraceFn(rootTrace, sink),
|
|
509
|
+
};
|
|
510
|
+
|
|
511
|
+
return { record: recordWithAttrs, tracedCtx };
|
|
512
|
+
};
|
|
513
|
+
|
|
514
|
+
const buildUntracedContext = (ctx: TrailContext): TrailContext => {
|
|
515
|
+
const { [TRACE_CONTEXT_KEY]: _traceContext, ...extensions } =
|
|
516
|
+
ctx.extensions ?? {};
|
|
517
|
+
const hasExtensions = Object.keys(extensions).length > 0;
|
|
518
|
+
|
|
519
|
+
return {
|
|
520
|
+
...ctx,
|
|
521
|
+
extensions: hasExtensions ? extensions : undefined,
|
|
522
|
+
trace: passthroughTrace,
|
|
523
|
+
};
|
|
524
|
+
};
|
|
525
|
+
|
|
526
|
+
/** Run the composed implementation and write the root record on any outcome. */
|
|
527
|
+
const runImplWithRootRecord = async (
|
|
528
|
+
impl: Implementation<unknown, unknown>,
|
|
529
|
+
input: unknown,
|
|
530
|
+
tracedCtx: TrailContext,
|
|
531
|
+
record: TraceRecord,
|
|
532
|
+
sink: ReturnType<typeof getTraceSink>
|
|
533
|
+
): Promise<Result<unknown, Error>> => {
|
|
534
|
+
try {
|
|
535
|
+
const result = await impl(input, tracedCtx);
|
|
536
|
+
await waitForPendingFireDispatches(tracedCtx);
|
|
537
|
+
const outcome = deriveOutcome(result);
|
|
538
|
+
await writeToSink(
|
|
539
|
+
sink,
|
|
540
|
+
completeRecord(record, outcome.status, outcome.errorCategory)
|
|
541
|
+
);
|
|
542
|
+
return result;
|
|
543
|
+
} catch (error: unknown) {
|
|
544
|
+
await waitForPendingFireDispatches(tracedCtx);
|
|
545
|
+
// Normalize unexpected throws so the root record still reflects the error
|
|
546
|
+
// outcome. The outer executeTrail try/catch converts the thrown value into
|
|
547
|
+
// a Result.err(InternalError) for the caller.
|
|
548
|
+
const status: TraceRecord['status'] =
|
|
549
|
+
error instanceof CancelledError ? 'cancelled' : 'err';
|
|
550
|
+
const errorCategory = categorizeSpanError(error);
|
|
551
|
+
await writeToSink(sink, completeRecord(record, status, errorCategory));
|
|
552
|
+
throw error;
|
|
553
|
+
}
|
|
554
|
+
};
|
|
555
|
+
|
|
556
|
+
const resolveCrossTarget = (
|
|
557
|
+
trailOrId: AnyTrail | string,
|
|
558
|
+
topo: Topo | undefined
|
|
559
|
+
): Result<AnyTrail, Error> => {
|
|
560
|
+
if (typeof trailOrId !== 'string') {
|
|
561
|
+
return Result.ok(trailOrId);
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
if (topo === undefined) {
|
|
565
|
+
return Result.err(
|
|
566
|
+
new NotFoundError(
|
|
567
|
+
`Trail "${trailOrId}" cannot be crossed without topo access`
|
|
568
|
+
)
|
|
569
|
+
);
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
const target = topo.get(trailOrId);
|
|
573
|
+
return target
|
|
574
|
+
? Result.ok(target)
|
|
575
|
+
: Result.err(
|
|
576
|
+
new NotFoundError(
|
|
577
|
+
`Trail "${trailOrId}" not found in topo "${topo.name}"`
|
|
578
|
+
)
|
|
579
|
+
);
|
|
580
|
+
};
|
|
581
|
+
|
|
582
|
+
const collectConcurrentBranchResourceIds = (
|
|
583
|
+
target: AnyTrail,
|
|
584
|
+
topo: Topo | undefined
|
|
585
|
+
): Set<string> =>
|
|
586
|
+
new Set(
|
|
587
|
+
topo?.resourceIds() ?? target.resources.map((resource) => resource.id)
|
|
588
|
+
);
|
|
589
|
+
|
|
590
|
+
const stripInheritedResourceExtensions = (
|
|
591
|
+
ctx: TrailContext,
|
|
592
|
+
target: AnyTrail,
|
|
593
|
+
topo: Topo | undefined
|
|
594
|
+
): Record<string, unknown> => {
|
|
595
|
+
const resourceIds = collectConcurrentBranchResourceIds(target, topo);
|
|
596
|
+
const entries = Object.entries(ctx.extensions ?? {}).filter(
|
|
597
|
+
([key]) => !resourceIds.has(key)
|
|
598
|
+
);
|
|
599
|
+
return Object.fromEntries(entries);
|
|
600
|
+
};
|
|
601
|
+
|
|
602
|
+
const deriveConcurrentBranchObserveMetadata = (
|
|
603
|
+
ctx: TrailContext,
|
|
604
|
+
target: AnyTrail,
|
|
605
|
+
branchIndex: number
|
|
606
|
+
): Record<string, unknown> | undefined => {
|
|
607
|
+
// Only carry observe metadata forward when the parent ctx is using a
|
|
608
|
+
// topo-managed observe logger. Otherwise we'd attach branch fields to
|
|
609
|
+
// caller-supplied loggers that didn't opt into the structured contract.
|
|
610
|
+
if (ctx.extensions?.[OBSERVE_LOGGER_CONTEXT_KEY] !== true) {
|
|
611
|
+
return;
|
|
612
|
+
}
|
|
613
|
+
return {
|
|
614
|
+
...readObserveLoggerMetadata(ctx),
|
|
615
|
+
branchIndex,
|
|
616
|
+
crossedTrailId: target.id,
|
|
617
|
+
};
|
|
618
|
+
};
|
|
619
|
+
|
|
620
|
+
const buildConcurrentBranchExtensions = (
|
|
621
|
+
ctx: TrailContext,
|
|
622
|
+
target: AnyTrail,
|
|
623
|
+
topo: Topo | undefined,
|
|
624
|
+
branchIndex: number
|
|
625
|
+
): Record<string, unknown> => {
|
|
626
|
+
const stripped = stripInheritedResourceExtensions(ctx, target, topo);
|
|
627
|
+
const observeMetadata = deriveConcurrentBranchObserveMetadata(
|
|
628
|
+
ctx,
|
|
629
|
+
target,
|
|
630
|
+
branchIndex
|
|
631
|
+
);
|
|
632
|
+
if (observeMetadata === undefined) {
|
|
633
|
+
return stripped;
|
|
634
|
+
}
|
|
635
|
+
return {
|
|
636
|
+
...stripped,
|
|
637
|
+
[OBSERVE_LOGGER_METADATA_KEY]: observeMetadata,
|
|
638
|
+
};
|
|
639
|
+
};
|
|
640
|
+
|
|
641
|
+
const deriveConcurrentBranchLogger = (
|
|
642
|
+
ctx: TrailContext,
|
|
643
|
+
target: AnyTrail,
|
|
644
|
+
branchIndex: number
|
|
645
|
+
) =>
|
|
646
|
+
ctx.logger?.child?.({
|
|
647
|
+
branchIndex,
|
|
648
|
+
crossedTrailId: target.id,
|
|
649
|
+
}) ?? ctx.logger;
|
|
650
|
+
|
|
651
|
+
/**
|
|
652
|
+
* Build a child context for one concurrent crossing branch.
|
|
653
|
+
*
|
|
654
|
+
* Concurrent crossings should not inherit already-resolved resource instances
|
|
655
|
+
* from the parent execution scope. Stripping resource IDs from extensions
|
|
656
|
+
* forces each branch to resolve its own scope while still carrying forward
|
|
657
|
+
* request-scoped values like tracing, surface identity, permits, and the
|
|
658
|
+
* shared AbortSignal.
|
|
659
|
+
*
|
|
660
|
+
* `cross`, `fire`, and `resource` are cleared so the child execution can
|
|
661
|
+
* rebind them to the branch-local context instead of reusing closures that
|
|
662
|
+
* capture the parent scope.
|
|
663
|
+
*/
|
|
664
|
+
const buildConcurrentBranchContext = (
|
|
665
|
+
ctx: TrailContext,
|
|
666
|
+
target: AnyTrail,
|
|
667
|
+
topo: Topo | undefined,
|
|
668
|
+
branchIndex: number
|
|
669
|
+
): TrailContext =>
|
|
670
|
+
forkCtx(ctx, {
|
|
671
|
+
extensions: buildConcurrentBranchExtensions(ctx, target, topo, branchIndex),
|
|
672
|
+
logger: deriveConcurrentBranchLogger(ctx, target, branchIndex),
|
|
673
|
+
});
|
|
674
|
+
|
|
675
|
+
const executeResolvedCrossTarget = async (
|
|
676
|
+
target: AnyTrail,
|
|
677
|
+
input: unknown,
|
|
678
|
+
ctx: TrailContext,
|
|
679
|
+
topo: Topo | undefined,
|
|
680
|
+
forwarded: Omit<ExecuteTrailOptions, 'createContext' | 'validationSchema'>
|
|
681
|
+
): Promise<Result<unknown, Error>> =>
|
|
682
|
+
await // eslint-disable-next-line no-use-before-define -- executor closure runs only after executeTrail is defined
|
|
683
|
+
executeTrail(target, input, {
|
|
684
|
+
...forwarded,
|
|
685
|
+
ctx,
|
|
686
|
+
topo,
|
|
687
|
+
validationSchema: buildCrossValidationSchema(target),
|
|
688
|
+
});
|
|
689
|
+
|
|
690
|
+
const executeCrossTarget = async (
|
|
691
|
+
trailOrId: AnyTrail | string,
|
|
692
|
+
input: unknown,
|
|
693
|
+
ctx: TrailContext,
|
|
694
|
+
topo: Topo | undefined,
|
|
695
|
+
forwarded: Omit<ExecuteTrailOptions, 'createContext' | 'validationSchema'>
|
|
696
|
+
): Promise<Result<unknown, Error>> => {
|
|
697
|
+
const target = resolveCrossTarget(trailOrId, topo);
|
|
698
|
+
if (target.isErr()) {
|
|
699
|
+
return target;
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
return await executeResolvedCrossTarget(
|
|
703
|
+
target.value,
|
|
704
|
+
input,
|
|
705
|
+
ctx,
|
|
706
|
+
topo,
|
|
707
|
+
forwarded
|
|
708
|
+
);
|
|
709
|
+
};
|
|
710
|
+
|
|
711
|
+
type CrossBatchCall = readonly [AnyTrail | string, unknown];
|
|
712
|
+
|
|
713
|
+
const executeConcurrentCrossBatchCall = async (
|
|
714
|
+
call: CrossBatchCall,
|
|
715
|
+
branchIndex: number,
|
|
716
|
+
ctx: TrailContext,
|
|
717
|
+
topo: Topo | undefined,
|
|
718
|
+
forwarded: Omit<ExecuteTrailOptions, 'createContext' | 'validationSchema'>
|
|
719
|
+
): Promise<Result<unknown, Error>> => {
|
|
720
|
+
const [trailOrId, batchInput] = call;
|
|
721
|
+
const target = resolveCrossTarget(trailOrId, topo);
|
|
722
|
+
if (target.isErr()) {
|
|
723
|
+
return target;
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
return await executeResolvedCrossTarget(
|
|
727
|
+
target.value,
|
|
728
|
+
batchInput,
|
|
729
|
+
buildConcurrentBranchContext(ctx, target.value, topo, branchIndex),
|
|
730
|
+
topo,
|
|
731
|
+
forwarded
|
|
732
|
+
);
|
|
733
|
+
};
|
|
734
|
+
|
|
735
|
+
const executeUnlimitedCrossBatch = async (
|
|
736
|
+
calls: readonly CrossBatchCall[],
|
|
737
|
+
ctx: TrailContext,
|
|
738
|
+
topo: Topo | undefined,
|
|
739
|
+
forwarded: Omit<ExecuteTrailOptions, 'createContext' | 'validationSchema'>
|
|
740
|
+
): Promise<Result<unknown, Error>[]> =>
|
|
741
|
+
await Promise.all(
|
|
742
|
+
calls.map((call, branchIndex) =>
|
|
743
|
+
executeConcurrentCrossBatchCall(call, branchIndex, ctx, topo, forwarded)
|
|
744
|
+
)
|
|
745
|
+
);
|
|
746
|
+
|
|
747
|
+
const createCrossBatchResults = (
|
|
748
|
+
calls: readonly CrossBatchCall[]
|
|
749
|
+
): Result<unknown, Error>[] =>
|
|
750
|
+
Array.from<Result<unknown, Error>>({ length: calls.length });
|
|
751
|
+
|
|
752
|
+
const executeLimitedCrossBatch = async (
|
|
753
|
+
calls: readonly CrossBatchCall[],
|
|
754
|
+
ctx: TrailContext,
|
|
755
|
+
topo: Topo | undefined,
|
|
756
|
+
forwarded: Omit<ExecuteTrailOptions, 'createContext' | 'validationSchema'>,
|
|
757
|
+
limit: number
|
|
758
|
+
): Promise<Result<unknown, Error>[]> => {
|
|
759
|
+
const results = createCrossBatchResults(calls);
|
|
760
|
+
const nextIndex = { value: 0 };
|
|
761
|
+
|
|
762
|
+
const runWorker = async () => {
|
|
763
|
+
while (true) {
|
|
764
|
+
const branchIndex = claimNextCrossBatchIndex(nextIndex, calls);
|
|
765
|
+
if (branchIndex === undefined) {
|
|
766
|
+
return;
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
const call = calls[branchIndex];
|
|
770
|
+
if (call === undefined) {
|
|
771
|
+
// Defensive: `claimNextCrossBatchIndex` only returns indices within
|
|
772
|
+
// bounds, so this slot should always be populated. If it ever isn't,
|
|
773
|
+
// surface a clear InternalError in place of the missing slot and keep
|
|
774
|
+
// the worker loop running so sibling branches still get processed.
|
|
775
|
+
results[branchIndex] = Result.err(
|
|
776
|
+
new InternalError(
|
|
777
|
+
`unreachable: concurrent cross batch call missing at index ${branchIndex}`
|
|
778
|
+
)
|
|
779
|
+
);
|
|
780
|
+
continue;
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
results[branchIndex] = await executeConcurrentCrossBatchCall(
|
|
784
|
+
call,
|
|
785
|
+
branchIndex,
|
|
786
|
+
ctx,
|
|
787
|
+
topo,
|
|
788
|
+
forwarded
|
|
789
|
+
);
|
|
790
|
+
}
|
|
791
|
+
};
|
|
792
|
+
|
|
793
|
+
await Promise.all(Array.from({ length: limit }, runWorker));
|
|
794
|
+
return results;
|
|
795
|
+
};
|
|
796
|
+
|
|
797
|
+
const executeCrossBatch = async (
|
|
798
|
+
calls: readonly CrossBatchCall[],
|
|
799
|
+
ctx: TrailContext,
|
|
800
|
+
topo: Topo | undefined,
|
|
801
|
+
forwarded: Omit<ExecuteTrailOptions, 'createContext' | 'validationSchema'>,
|
|
802
|
+
batchOptions?: CrossBatchOptions
|
|
803
|
+
): Promise<Result<unknown, Error>[]> => {
|
|
804
|
+
if (calls.length === 0) {
|
|
805
|
+
return [];
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
const concurrency = normalizeCrossBatchConcurrency(batchOptions);
|
|
809
|
+
if (concurrency.isErr()) {
|
|
810
|
+
return createCrossBatchValidationResults(calls, concurrency.error);
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
const limit = concurrency.value ?? calls.length;
|
|
814
|
+
return limit >= calls.length
|
|
815
|
+
? await executeUnlimitedCrossBatch(calls, ctx, topo, forwarded)
|
|
816
|
+
: await executeLimitedCrossBatch(calls, ctx, topo, forwarded, limit);
|
|
817
|
+
};
|
|
818
|
+
|
|
819
|
+
const bindCrossToCtx = (
|
|
820
|
+
ctx: TrailContext,
|
|
821
|
+
topo: Topo | undefined,
|
|
822
|
+
options: ExecuteTrailOptions | undefined
|
|
823
|
+
): TrailContext => {
|
|
824
|
+
if (ctx.cross !== undefined) {
|
|
825
|
+
return ctx;
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
const {
|
|
829
|
+
createContext: _omit,
|
|
830
|
+
validationSchema: _omitSchema,
|
|
831
|
+
...forwarded
|
|
832
|
+
} = options ?? {};
|
|
833
|
+
const cross = (async (
|
|
834
|
+
trailOrCalls:
|
|
835
|
+
| AnyTrail
|
|
836
|
+
| string
|
|
837
|
+
| readonly (readonly [AnyTrail | string, unknown])[],
|
|
838
|
+
inputOrOptions?: CrossBatchOptions | unknown
|
|
839
|
+
) => {
|
|
840
|
+
if (Array.isArray(trailOrCalls)) {
|
|
841
|
+
return await executeCrossBatch(
|
|
842
|
+
trailOrCalls,
|
|
843
|
+
ctx,
|
|
844
|
+
topo,
|
|
845
|
+
forwarded,
|
|
846
|
+
inputOrOptions as CrossBatchOptions | undefined
|
|
847
|
+
);
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
return await executeCrossTarget(
|
|
851
|
+
trailOrCalls as AnyTrail | string,
|
|
852
|
+
inputOrOptions,
|
|
853
|
+
ctx,
|
|
854
|
+
topo,
|
|
855
|
+
forwarded
|
|
856
|
+
);
|
|
857
|
+
}) as CrossFn;
|
|
858
|
+
|
|
859
|
+
return {
|
|
860
|
+
...ctx,
|
|
861
|
+
cross,
|
|
862
|
+
};
|
|
863
|
+
};
|
|
864
|
+
|
|
865
|
+
const bindFireToCtx = (
|
|
866
|
+
ctx: TrailContext,
|
|
867
|
+
topo: Topo | undefined,
|
|
868
|
+
options: ExecuteTrailOptions | undefined,
|
|
869
|
+
producerTrailId?: string | undefined
|
|
870
|
+
): TrailContext => {
|
|
871
|
+
// Symmetric with bindCrossToCtx: a caller-supplied ctx.fire (e.g. test
|
|
872
|
+
// helper, scenario harness, or runtime intercepting signal fan-out) is
|
|
873
|
+
// preserved as-is. Without this guard, passing both `topo: app` and a
|
|
874
|
+
// custom `ctx.fire` would silently clobber the injected mock with the
|
|
875
|
+
// topo-backed dispatcher. Framework-created fire functions are replaced
|
|
876
|
+
// because consumer fan-out seeds them before the consumer trace span exists.
|
|
877
|
+
if (ctx.fire !== undefined && !isFrameworkFireFn(ctx.fire)) {
|
|
878
|
+
return ctx;
|
|
879
|
+
}
|
|
880
|
+
if (topo === undefined) {
|
|
881
|
+
return ctx;
|
|
882
|
+
}
|
|
883
|
+
// Forward the producer's execution options to consumers so resources,
|
|
884
|
+
// layers, configValues, and abortSignal propagate through signal fan-out.
|
|
885
|
+
// `createContext` is intentionally stripped — consumers inherit the
|
|
886
|
+
// already-resolved ctx via `consumerCtx`, and re-running the factory would
|
|
887
|
+
// clobber that.
|
|
888
|
+
// Strip createContext (consumers inherit resolved ctx) and validationSchema
|
|
889
|
+
// (consumers validate against their own schema, not the producer's cross schema).
|
|
890
|
+
const {
|
|
891
|
+
createContext: _omit,
|
|
892
|
+
validationSchema: _omitSchema,
|
|
893
|
+
...forwarded
|
|
894
|
+
} = options ?? {};
|
|
895
|
+
const trackedCtx = withFireDispatchTracking(ctx);
|
|
896
|
+
const fire = createFireFn(
|
|
897
|
+
topo,
|
|
898
|
+
trackedCtx,
|
|
899
|
+
(consumer, input, consumerCtx) =>
|
|
900
|
+
// eslint-disable-next-line no-use-before-define -- executor closure runs only after executeTrail is defined
|
|
901
|
+
executeTrail(consumer, input, {
|
|
902
|
+
...forwarded,
|
|
903
|
+
ctx: consumerCtx,
|
|
904
|
+
topo,
|
|
905
|
+
}),
|
|
906
|
+
producerTrailId
|
|
907
|
+
);
|
|
908
|
+
return { ...trackedCtx, fire };
|
|
909
|
+
};
|
|
910
|
+
|
|
911
|
+
const bindCrossAtLayerBoundary =
|
|
912
|
+
<I, O>(
|
|
913
|
+
implementation: Implementation<I, O>,
|
|
914
|
+
topo: Topo | undefined,
|
|
915
|
+
options: ExecuteTrailOptions | undefined
|
|
916
|
+
): Implementation<I, O> =>
|
|
917
|
+
(input, ctx) =>
|
|
918
|
+
implementation(input, bindCrossToCtx(ctx, topo, options));
|
|
919
|
+
|
|
920
|
+
const bindFireAtLayerBoundary = <I, O>(
|
|
921
|
+
implementation: Implementation<I, O>,
|
|
922
|
+
trail: AnyTrail,
|
|
923
|
+
topo: Topo | undefined,
|
|
924
|
+
options: ExecuteTrailOptions | undefined
|
|
925
|
+
): Implementation<I, O> => {
|
|
926
|
+
if (topo === undefined) {
|
|
927
|
+
return implementation;
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
return (input, ctx) =>
|
|
931
|
+
implementation(input, bindFireToCtx(ctx, topo, options, trail.id));
|
|
932
|
+
};
|
|
933
|
+
|
|
934
|
+
// ---------------------------------------------------------------------------
|
|
935
|
+
// Detour loop
|
|
936
|
+
// ---------------------------------------------------------------------------
|
|
937
|
+
|
|
938
|
+
/**
|
|
939
|
+
* Find the first detour whose `on` class matches the error via `instanceof`.
|
|
940
|
+
*
|
|
941
|
+
* Declaration order wins — no most-specific-first hierarchy walking.
|
|
942
|
+
*/
|
|
943
|
+
const findMatchingDetour = (
|
|
944
|
+
/* oxlint-disable-next-line no-explicit-any -- existential detour array from AnyTrail */
|
|
945
|
+
detours: readonly Detour<any, any, TrailsError>[],
|
|
946
|
+
error: TrailsError
|
|
947
|
+
/* oxlint-disable-next-line no-explicit-any -- matched detour carries runtime generics */
|
|
948
|
+
): Detour<any, any, TrailsError> | undefined =>
|
|
949
|
+
detours.find((d) => error instanceof d.on);
|
|
950
|
+
|
|
951
|
+
/** Execute a single detour recovery attempt, routing through ctx.trace when available. */
|
|
952
|
+
const executeDetourAttempt = async (
|
|
953
|
+
/* oxlint-disable-next-line no-explicit-any -- existential detour from AnyTrail */
|
|
954
|
+
detour: Detour<any, any, TrailsError>,
|
|
955
|
+
attempt: number,
|
|
956
|
+
lastError: TrailsError,
|
|
957
|
+
input: unknown,
|
|
958
|
+
ctx: TrailContext
|
|
959
|
+
): Promise<Result<unknown, Error>> => {
|
|
960
|
+
const run = async () =>
|
|
961
|
+
await detour.recover({ attempt, error: lastError, input }, ctx);
|
|
962
|
+
|
|
963
|
+
return ctx.trace
|
|
964
|
+
? await ctx.trace(`detour:${detour.on.name}:${attempt}`, run)
|
|
965
|
+
: await run();
|
|
966
|
+
};
|
|
967
|
+
|
|
968
|
+
/** Classify a detour attempt result: continue the loop, or return early. */
|
|
969
|
+
const classifyDetourResult = (
|
|
970
|
+
result: Result<unknown, Error>,
|
|
971
|
+
/* oxlint-disable-next-line no-explicit-any -- existential detour from AnyTrail */
|
|
972
|
+
detour: Detour<any, any, TrailsError>
|
|
973
|
+
):
|
|
974
|
+
| { readonly done: true; readonly result: Result<unknown, Error> }
|
|
975
|
+
| { readonly done: false; readonly nextError: TrailsError } => {
|
|
976
|
+
if (result.isOk()) {
|
|
977
|
+
return { done: true, result };
|
|
978
|
+
}
|
|
979
|
+
const recoverError = result.error;
|
|
980
|
+
if (
|
|
981
|
+
!(recoverError instanceof TrailsError) ||
|
|
982
|
+
!(recoverError instanceof detour.on)
|
|
983
|
+
) {
|
|
984
|
+
return { done: true, result };
|
|
985
|
+
}
|
|
986
|
+
return { done: false, nextError: recoverError };
|
|
987
|
+
};
|
|
988
|
+
|
|
989
|
+
/** Resolve effective maxAttempts, warning if the declared value exceeds the hard cap. */
|
|
990
|
+
const resolveMaxAttempts = (
|
|
991
|
+
/* oxlint-disable-next-line no-explicit-any -- existential detour from AnyTrail */
|
|
992
|
+
detour: Detour<any, any, TrailsError>,
|
|
993
|
+
ctx: TrailContext
|
|
994
|
+
): number => {
|
|
995
|
+
const declared = detour.maxAttempts ?? 1;
|
|
996
|
+
const clamped = Math.max(1, Math.min(declared, DETOUR_MAX_ATTEMPTS_CAP));
|
|
997
|
+
if (clamped === declared) {
|
|
998
|
+
return clamped;
|
|
999
|
+
}
|
|
1000
|
+
ctx.logger?.warn('detour maxAttempts clamped', {
|
|
1001
|
+
declared,
|
|
1002
|
+
detour: detour.on.name,
|
|
1003
|
+
effective: clamped,
|
|
1004
|
+
});
|
|
1005
|
+
return clamped;
|
|
1006
|
+
};
|
|
1007
|
+
|
|
1008
|
+
/** Run the detour recovery loop for a single matched detour. */
|
|
1009
|
+
const runDetourRecovery = async (
|
|
1010
|
+
/* oxlint-disable-next-line no-explicit-any -- existential detour from AnyTrail */
|
|
1011
|
+
detour: Detour<any, any, TrailsError>,
|
|
1012
|
+
error: TrailsError,
|
|
1013
|
+
input: unknown,
|
|
1014
|
+
ctx: TrailContext
|
|
1015
|
+
): Promise<Result<unknown, Error>> => {
|
|
1016
|
+
const maxAttempts = resolveMaxAttempts(detour, ctx);
|
|
1017
|
+
let lastError: TrailsError = error;
|
|
1018
|
+
|
|
1019
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
|
1020
|
+
ctx.logger?.debug('detour recovery attempt', {
|
|
1021
|
+
attempt,
|
|
1022
|
+
errorClass: lastError.name,
|
|
1023
|
+
matchedDetour: detour.on.name,
|
|
1024
|
+
maxAttempts,
|
|
1025
|
+
});
|
|
1026
|
+
const result = await executeDetourAttempt(
|
|
1027
|
+
detour,
|
|
1028
|
+
attempt,
|
|
1029
|
+
lastError,
|
|
1030
|
+
input,
|
|
1031
|
+
ctx
|
|
1032
|
+
);
|
|
1033
|
+
const classification = classifyDetourResult(result, detour);
|
|
1034
|
+
if (classification.done) {
|
|
1035
|
+
return classification.result;
|
|
1036
|
+
}
|
|
1037
|
+
lastError = classification.nextError;
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
return Result.err(
|
|
1041
|
+
new RetryExhaustedError(lastError, {
|
|
1042
|
+
attempts: maxAttempts,
|
|
1043
|
+
detour: detour.on.name,
|
|
1044
|
+
})
|
|
1045
|
+
);
|
|
1046
|
+
};
|
|
1047
|
+
|
|
1048
|
+
/**
|
|
1049
|
+
* Wrap a blaze with the detour recovery loop.
|
|
1050
|
+
*
|
|
1051
|
+
* If the trail has no detours, returns the blaze unchanged (no wrapper overhead).
|
|
1052
|
+
* The detour loop runs inside the layer stack, closest to the blaze.
|
|
1053
|
+
*/
|
|
1054
|
+
const wrapWithDetours = (
|
|
1055
|
+
blaze: Implementation<unknown, unknown>,
|
|
1056
|
+
/* oxlint-disable-next-line no-explicit-any -- existential detour array from AnyTrail */
|
|
1057
|
+
detours: readonly Detour<any, any, TrailsError>[]
|
|
1058
|
+
): Implementation<unknown, unknown> => {
|
|
1059
|
+
if (detours.length === 0) {
|
|
1060
|
+
return blaze;
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
return async (input, ctx) => {
|
|
1064
|
+
const result = await blaze(input, ctx);
|
|
1065
|
+
if (result.isOk()) {
|
|
1066
|
+
return result;
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
const { error } = result;
|
|
1070
|
+
if (!(error instanceof TrailsError)) {
|
|
1071
|
+
return result;
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
const matched = findMatchingDetour(detours, error);
|
|
1075
|
+
if (matched === undefined) {
|
|
1076
|
+
return result;
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
return await runDetourRecovery(matched, error, input, ctx);
|
|
1080
|
+
};
|
|
1081
|
+
};
|
|
1082
|
+
|
|
1083
|
+
const wrapWithOutputValidation = (
|
|
1084
|
+
trail: AnyTrail,
|
|
1085
|
+
implementation: Implementation<unknown, unknown>
|
|
1086
|
+
): Implementation<unknown, unknown> => {
|
|
1087
|
+
const { output } = trail;
|
|
1088
|
+
if (output === undefined) {
|
|
1089
|
+
return implementation;
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
return async (input, ctx) => {
|
|
1093
|
+
const result = await implementation(input, ctx);
|
|
1094
|
+
if (result.isErr()) {
|
|
1095
|
+
return result;
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
const validated = validateOutput(output, result.value);
|
|
1099
|
+
return validated.isErr()
|
|
1100
|
+
? Result.err(validated.error)
|
|
1101
|
+
: Result.ok(validated.value);
|
|
1102
|
+
};
|
|
1103
|
+
};
|
|
1104
|
+
|
|
1105
|
+
const prepareRunImpl = (
|
|
1106
|
+
trail: AnyTrail,
|
|
1107
|
+
ctx: TrailContext,
|
|
1108
|
+
layers: readonly Layer[],
|
|
1109
|
+
topo: Topo | undefined,
|
|
1110
|
+
options: ExecuteTrailOptions | undefined
|
|
1111
|
+
): {
|
|
1112
|
+
readonly ctxWithIntrinsics: TrailContext;
|
|
1113
|
+
readonly impl: Implementation<unknown, unknown>;
|
|
1114
|
+
} => {
|
|
1115
|
+
const ctxWithIntrinsics = bindFireToCtx(
|
|
1116
|
+
bindCrossToCtx(ctx, topo, options),
|
|
1117
|
+
topo,
|
|
1118
|
+
options,
|
|
1119
|
+
trail.id
|
|
1120
|
+
);
|
|
1121
|
+
// Detour loop wraps the blaze (inside layer stack, closest to blaze)
|
|
1122
|
+
let impl = wrapWithDetours(
|
|
1123
|
+
bindFireAtLayerBoundary(
|
|
1124
|
+
bindCrossAtLayerBoundary(
|
|
1125
|
+
trail.blaze as Implementation<unknown, unknown>,
|
|
1126
|
+
topo,
|
|
1127
|
+
options
|
|
1128
|
+
),
|
|
1129
|
+
trail,
|
|
1130
|
+
topo,
|
|
1131
|
+
options
|
|
1132
|
+
),
|
|
1133
|
+
trail.detours
|
|
1134
|
+
);
|
|
1135
|
+
|
|
1136
|
+
for (let i = layers.length - 1; i >= 0; i -= 1) {
|
|
1137
|
+
const layer = layers[i];
|
|
1138
|
+
if (layer) {
|
|
1139
|
+
impl = bindFireAtLayerBoundary(
|
|
1140
|
+
bindCrossAtLayerBoundary(
|
|
1141
|
+
layer.wrap(trail, impl as never) as Implementation<unknown, unknown>,
|
|
1142
|
+
topo,
|
|
1143
|
+
options
|
|
1144
|
+
),
|
|
1145
|
+
trail,
|
|
1146
|
+
topo,
|
|
1147
|
+
options
|
|
1148
|
+
);
|
|
1149
|
+
}
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
return {
|
|
1153
|
+
ctxWithIntrinsics,
|
|
1154
|
+
impl: wrapWithOutputValidation(trail, impl),
|
|
1155
|
+
};
|
|
1156
|
+
};
|
|
1157
|
+
|
|
1158
|
+
const runImplWithoutTracing = async (
|
|
1159
|
+
trail: AnyTrail,
|
|
1160
|
+
input: unknown,
|
|
1161
|
+
ctx: TrailContext,
|
|
1162
|
+
layers: readonly Layer[],
|
|
1163
|
+
topo: Topo | undefined,
|
|
1164
|
+
options: ExecuteTrailOptions | undefined
|
|
1165
|
+
): Promise<Result<unknown, Error>> => {
|
|
1166
|
+
const prepared = prepareRunImpl(
|
|
1167
|
+
trail,
|
|
1168
|
+
buildUntracedContext(ctx),
|
|
1169
|
+
layers,
|
|
1170
|
+
topo,
|
|
1171
|
+
options
|
|
1172
|
+
);
|
|
1173
|
+
try {
|
|
1174
|
+
return await prepared.impl(input, prepared.ctxWithIntrinsics);
|
|
1175
|
+
} finally {
|
|
1176
|
+
await waitForPendingFireDispatches(prepared.ctxWithIntrinsics);
|
|
1177
|
+
}
|
|
1178
|
+
};
|
|
1179
|
+
|
|
1180
|
+
const runTrailWithTracing = async (
|
|
1181
|
+
trail: AnyTrail,
|
|
1182
|
+
input: unknown,
|
|
1183
|
+
ctx: TrailContext,
|
|
1184
|
+
layers: readonly Layer[],
|
|
1185
|
+
topo: Topo | undefined,
|
|
1186
|
+
options: ExecuteTrailOptions | undefined,
|
|
1187
|
+
sink: ReturnType<typeof getTraceSink>
|
|
1188
|
+
): Promise<Result<unknown, Error>> => {
|
|
1189
|
+
const { record, tracedCtx } = buildTracedContext(trail, ctx, sink);
|
|
1190
|
+
let prepared: ReturnType<typeof prepareRunImpl>;
|
|
1191
|
+
|
|
1192
|
+
try {
|
|
1193
|
+
prepared = prepareRunImpl(trail, tracedCtx, layers, topo, options);
|
|
1194
|
+
} catch (error: unknown) {
|
|
1195
|
+
const status: TraceRecord['status'] =
|
|
1196
|
+
error instanceof CancelledError ? 'cancelled' : 'err';
|
|
1197
|
+
await writeToSink(
|
|
1198
|
+
sink,
|
|
1199
|
+
completeRecord(record, status, categorizeSpanError(error))
|
|
1200
|
+
);
|
|
1201
|
+
throw error;
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
return await runImplWithRootRecord(
|
|
1205
|
+
prepared.impl,
|
|
1206
|
+
input,
|
|
1207
|
+
prepared.ctxWithIntrinsics,
|
|
1208
|
+
record,
|
|
1209
|
+
sink
|
|
1210
|
+
);
|
|
118
1211
|
};
|
|
119
1212
|
|
|
120
1213
|
const runTrail = async (
|
|
121
1214
|
trail: AnyTrail,
|
|
122
1215
|
input: unknown,
|
|
123
1216
|
ctx: TrailContext,
|
|
124
|
-
|
|
1217
|
+
layers: readonly Layer[],
|
|
1218
|
+
topo: Topo | undefined,
|
|
1219
|
+
options: ExecuteTrailOptions | undefined
|
|
125
1220
|
): Promise<Result<unknown, Error>> => {
|
|
126
|
-
const
|
|
127
|
-
return
|
|
1221
|
+
const sink = topo?.observe?.trace ?? getTraceSink();
|
|
1222
|
+
return isTracingDisabled(sink)
|
|
1223
|
+
? await runImplWithoutTracing(trail, input, ctx, layers, topo, options)
|
|
1224
|
+
: await runTrailWithTracing(trail, input, ctx, layers, topo, options, sink);
|
|
128
1225
|
};
|
|
129
1226
|
|
|
130
1227
|
// ---------------------------------------------------------------------------
|
|
@@ -132,7 +1229,103 @@ const runTrail = async (
|
|
|
132
1229
|
// ---------------------------------------------------------------------------
|
|
133
1230
|
|
|
134
1231
|
/**
|
|
135
|
-
*
|
|
1232
|
+
* Compose the typed layers attached at topo, surface, and trail scope.
|
|
1233
|
+
*
|
|
1234
|
+
* Composition order is topo → surface → trail → execution-supplied → blaze
|
|
1235
|
+
* (outermost-first): trail-scope layers run inside surface/topo layers, and
|
|
1236
|
+
* `executeTrail({ layers })` layers wrap closest to the blaze for per-call
|
|
1237
|
+
* behavior.
|
|
1238
|
+
*/
|
|
1239
|
+
const composeAttachedLayers = (
|
|
1240
|
+
trail: AnyTrail,
|
|
1241
|
+
options: ExecuteTrailOptions | undefined
|
|
1242
|
+
): readonly Layer[] => [
|
|
1243
|
+
...(options?.topoLayers ?? []),
|
|
1244
|
+
...(options?.surfaceLayers ?? []),
|
|
1245
|
+
...trail.layers,
|
|
1246
|
+
...(options?.layers ?? []),
|
|
1247
|
+
];
|
|
1248
|
+
|
|
1249
|
+
const isLayerInputMap = (
|
|
1250
|
+
value: unknown
|
|
1251
|
+
): value is Readonly<Record<string, unknown>> =>
|
|
1252
|
+
value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
1253
|
+
|
|
1254
|
+
const readContextLayerInputs = (
|
|
1255
|
+
ctx: TrailContext
|
|
1256
|
+
): Result<Readonly<Record<string, unknown>> | undefined, ValidationError> => {
|
|
1257
|
+
const value = ctx.extensions?.[LAYER_INPUTS_KEY];
|
|
1258
|
+
if (value === undefined) {
|
|
1259
|
+
return Result.ok();
|
|
1260
|
+
}
|
|
1261
|
+
return isLayerInputMap(value)
|
|
1262
|
+
? Result.ok(value)
|
|
1263
|
+
: Result.err(
|
|
1264
|
+
new ValidationError(
|
|
1265
|
+
'Layer inputs must be an object keyed by layer name',
|
|
1266
|
+
{
|
|
1267
|
+
context: { extensionKey: LAYER_INPUTS_KEY },
|
|
1268
|
+
}
|
|
1269
|
+
)
|
|
1270
|
+
);
|
|
1271
|
+
};
|
|
1272
|
+
|
|
1273
|
+
const validateLayerInputs = (
|
|
1274
|
+
layers: readonly Layer[],
|
|
1275
|
+
layerInputs: Readonly<Record<string, unknown>>
|
|
1276
|
+
): Result<Readonly<Record<string, unknown>>, ValidationError> => {
|
|
1277
|
+
const validated: Record<string, unknown> = { ...layerInputs };
|
|
1278
|
+
for (const layer of layers) {
|
|
1279
|
+
if (layer.input === undefined) {
|
|
1280
|
+
continue;
|
|
1281
|
+
}
|
|
1282
|
+
const slot = layerInputs[layer.name];
|
|
1283
|
+
if (slot === undefined) {
|
|
1284
|
+
continue;
|
|
1285
|
+
}
|
|
1286
|
+
const parsed = layer.input.safeParse(slot);
|
|
1287
|
+
if (!parsed.success) {
|
|
1288
|
+
return Result.err(
|
|
1289
|
+
new ValidationError(
|
|
1290
|
+
`Invalid input for layer '${layer.name}': ${parsed.error.message}`,
|
|
1291
|
+
{
|
|
1292
|
+
cause: parsed.error,
|
|
1293
|
+
context: { issues: parsed.error.issues, layerName: layer.name },
|
|
1294
|
+
}
|
|
1295
|
+
)
|
|
1296
|
+
);
|
|
1297
|
+
}
|
|
1298
|
+
validated[layer.name] = parsed.data;
|
|
1299
|
+
}
|
|
1300
|
+
return Result.ok(validated);
|
|
1301
|
+
};
|
|
1302
|
+
|
|
1303
|
+
const validateContextLayerInputs = (
|
|
1304
|
+
ctx: TrailContext,
|
|
1305
|
+
layers: readonly Layer[]
|
|
1306
|
+
): Result<TrailContext, ValidationError> => {
|
|
1307
|
+
const layerInputs = readContextLayerInputs(ctx);
|
|
1308
|
+
if (layerInputs.isErr()) {
|
|
1309
|
+
return Result.err(layerInputs.error);
|
|
1310
|
+
}
|
|
1311
|
+
if (layerInputs.value === undefined) {
|
|
1312
|
+
return Result.ok(ctx);
|
|
1313
|
+
}
|
|
1314
|
+
const validated = validateLayerInputs(layers, layerInputs.value);
|
|
1315
|
+
if (validated.isErr()) {
|
|
1316
|
+
return Result.err(validated.error);
|
|
1317
|
+
}
|
|
1318
|
+
return Result.ok({
|
|
1319
|
+
...ctx,
|
|
1320
|
+
extensions: {
|
|
1321
|
+
...ctx.extensions,
|
|
1322
|
+
[LAYER_INPUTS_KEY]: validated.value,
|
|
1323
|
+
},
|
|
1324
|
+
});
|
|
1325
|
+
};
|
|
1326
|
+
|
|
1327
|
+
/**
|
|
1328
|
+
* Execute a trail through the standard validate-context-layers-run pipeline.
|
|
136
1329
|
*
|
|
137
1330
|
* The function never throws -- unexpected exceptions are caught and
|
|
138
1331
|
* returned as `Result.err(InternalError)`.
|
|
@@ -143,22 +1336,39 @@ export const executeTrail = async (
|
|
|
143
1336
|
options?: ExecuteTrailOptions
|
|
144
1337
|
): Promise<Result<unknown, Error>> => {
|
|
145
1338
|
try {
|
|
146
|
-
const validated = validateInput(
|
|
1339
|
+
const validated = validateInput(
|
|
1340
|
+
options?.validationSchema ?? trail.input,
|
|
1341
|
+
rawInput
|
|
1342
|
+
);
|
|
147
1343
|
if (validated.isErr()) {
|
|
148
1344
|
return validated;
|
|
149
1345
|
}
|
|
150
1346
|
|
|
151
1347
|
const resolvedCtx = await prepareContext(trail, options);
|
|
152
1348
|
if (resolvedCtx.isErr()) {
|
|
153
|
-
return resolvedCtx;
|
|
1349
|
+
return Result.err(resolvedCtx.error);
|
|
154
1350
|
}
|
|
155
1351
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
1352
|
+
const layers = composeAttachedLayers(trail, options);
|
|
1353
|
+
try {
|
|
1354
|
+
const layerCtx = validateContextLayerInputs(
|
|
1355
|
+
resolvedCtx.value.ctx,
|
|
1356
|
+
layers
|
|
1357
|
+
);
|
|
1358
|
+
if (layerCtx.isErr()) {
|
|
1359
|
+
return Result.err(layerCtx.error);
|
|
1360
|
+
}
|
|
1361
|
+
return await runTrail(
|
|
1362
|
+
trail,
|
|
1363
|
+
validated.value,
|
|
1364
|
+
layerCtx.value,
|
|
1365
|
+
layers,
|
|
1366
|
+
options?.topo,
|
|
1367
|
+
options
|
|
1368
|
+
);
|
|
1369
|
+
} finally {
|
|
1370
|
+
resolvedCtx.value.releaseResources();
|
|
1371
|
+
}
|
|
162
1372
|
} catch (error: unknown) {
|
|
163
1373
|
const message = error instanceof Error ? error.message : String(error);
|
|
164
1374
|
return Result.err(new InternalError(message));
|