@ontrails/core 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +849 -0
- package/README.md +190 -0
- package/package.json +36 -0
- package/src/activation-provenance.ts +116 -0
- package/src/activation-source-compatibility.ts +430 -0
- package/src/activation-source-derivation.ts +227 -0
- package/src/activation-source.ts +93 -0
- package/src/blob-ref.ts +90 -0
- package/src/branded.ts +135 -0
- package/src/collections.ts +99 -0
- package/src/compose-batch.ts +69 -0
- package/src/compose-schema.ts +36 -0
- package/src/context.ts +66 -0
- package/src/derive.ts +485 -0
- package/src/detours.ts +8 -0
- package/src/diagnostics.ts +21 -0
- package/src/draft.ts +350 -0
- package/src/entity.ts +346 -0
- package/src/error-rendering.ts +87 -0
- package/src/errors.ts +483 -0
- package/src/execute.ts +1577 -0
- package/src/fetch.ts +138 -0
- package/src/fire.ts +1172 -0
- package/src/glob.ts +81 -0
- package/src/guards.ts +37 -0
- package/src/index.ts +704 -0
- package/src/internal/fork-ctx.ts +69 -0
- package/src/layer-field-rendering.ts +193 -0
- package/src/layer.ts +81 -0
- package/src/observe.ts +361 -0
- package/src/path-scope.ts +66 -0
- package/src/path-security.ts +98 -0
- package/src/patterns/bulk.ts +16 -0
- package/src/patterns/change.ts +12 -0
- package/src/patterns/date-range.ts +12 -0
- package/src/patterns/index.ts +8 -0
- package/src/patterns/pagination.ts +22 -0
- package/src/patterns/progress.ts +13 -0
- package/src/patterns/sorting.ts +14 -0
- package/src/patterns/status.ts +11 -0
- package/src/patterns/timestamps.ts +12 -0
- package/src/permits.ts +12 -0
- package/src/queue.ts +163 -0
- package/src/redaction/index.ts +3 -0
- package/src/redaction/patterns.ts +50 -0
- package/src/redaction/redactor.ts +178 -0
- package/src/resilience.ts +234 -0
- package/src/resource-config.ts +804 -0
- package/src/resource.ts +194 -0
- package/src/result.ts +212 -0
- package/src/run.ts +76 -0
- package/src/runtime-builtins.ts +69 -0
- package/src/schedule-runtime.ts +689 -0
- package/src/schedule.ts +326 -0
- package/src/serialization.ts +265 -0
- package/src/sha256.ts +136 -0
- package/src/signal-diagnostics.ts +633 -0
- package/src/signal-ref.ts +111 -0
- package/src/signal.ts +104 -0
- package/src/store/accessor-protocol.ts +56 -0
- package/src/store/index.ts +4 -0
- package/src/structured-examples.ts +248 -0
- package/src/surface-derivation.ts +91 -0
- package/src/surface-filter.ts +101 -0
- package/src/surface-overlay.ts +694 -0
- package/src/surface-versioning.ts +42 -0
- package/src/topo.ts +835 -0
- package/src/tracing.ts +346 -0
- package/src/trail-id-glob.ts +15 -0
- package/src/trail.ts +1351 -0
- package/src/trails/derive-trail.ts +835 -0
- package/src/trails/index.ts +9 -0
- package/src/trails/ingest.ts +152 -0
- package/src/trails-db.ts +212 -0
- package/src/transport-error-map.ts +163 -0
- package/src/type-utils.ts +87 -0
- package/src/types.ts +300 -0
- package/src/validate-established-topo.ts +73 -0
- package/src/validate-topo.ts +725 -0
- package/src/validation.ts +330 -0
- package/src/version-marker.ts +716 -0
- package/src/version-resolution.ts +308 -0
- package/src/version-runtime.ts +120 -0
- package/src/webhook.ts +461 -0
- package/src/workspace.ts +244 -0
- package/src/zod-wrappers.ts +72 -0
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Internal helper for "forking" a trail context.
|
|
3
|
+
*
|
|
4
|
+
* Several execution sites need to derive a child context from a parent
|
|
5
|
+
* context while **resetting** a well-known set of bound closures (`compose`,
|
|
6
|
+
* `fire`, `resource`). Those closures capture the parent scope, so reusing
|
|
7
|
+
* them on the child would re-enter execution with the wrong attribution,
|
|
8
|
+
* the wrong resource scope, or the wrong fan-out identity. The reset list
|
|
9
|
+
* is the same at every fork site.
|
|
10
|
+
*
|
|
11
|
+
* Codifying the reset list in one helper keeps that contract in a single
|
|
12
|
+
* place. Callers supply the overrides they need (typically `env`,
|
|
13
|
+
* `extensions`, and `logger`) and let the helper handle the reset.
|
|
14
|
+
*
|
|
15
|
+
* This module is internal — do not export it from the package entry point.
|
|
16
|
+
*
|
|
17
|
+
* @remarks
|
|
18
|
+
* The helper is intentionally generic over the concrete context shape. Some
|
|
19
|
+
* callers work with the fully-resolved `TrailContext` produced inside the
|
|
20
|
+
* executor; others work with `Partial<TrailContextInit>` during consumer
|
|
21
|
+
* context derivation in `fire.ts`. Both shapes share the same reset keys.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import type { TrailContext, TrailContextInit } from '../types.js';
|
|
25
|
+
|
|
26
|
+
/** Keys cleared by default when forking a context. */
|
|
27
|
+
export type ForkCtxResetKey = 'compose' | 'fire' | 'resource';
|
|
28
|
+
|
|
29
|
+
/** Override fields callers are allowed to apply when forking a context. */
|
|
30
|
+
export type ForkCtxOverrides = Readonly<
|
|
31
|
+
Partial<Pick<TrailContext, 'env' | 'extensions' | 'logger'>>
|
|
32
|
+
>;
|
|
33
|
+
|
|
34
|
+
const DEFAULT_RESET_KEYS: readonly ForkCtxResetKey[] = [
|
|
35
|
+
'compose',
|
|
36
|
+
'fire',
|
|
37
|
+
'resource',
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Fork a parent context into a child context.
|
|
42
|
+
*
|
|
43
|
+
* Spreads the parent, clears each reset key to `undefined`, and applies the
|
|
44
|
+
* supplied overrides last so callers may replace logger, env, and extensions
|
|
45
|
+
* with branch-local values.
|
|
46
|
+
*
|
|
47
|
+
* The generic parameter is constrained to the minimal shape the helper
|
|
48
|
+
* reads and writes. This lets the helper serve both `TrailContext`
|
|
49
|
+
* (executor scope) and `Partial<TrailContextInit>` (fire-consumer scope)
|
|
50
|
+
* without widening the public surface of either type.
|
|
51
|
+
*/
|
|
52
|
+
export const forkCtx = <
|
|
53
|
+
TCtx extends Partial<
|
|
54
|
+
Pick<
|
|
55
|
+
TrailContextInit,
|
|
56
|
+
'compose' | 'env' | 'extensions' | 'fire' | 'logger' | 'resource'
|
|
57
|
+
>
|
|
58
|
+
>,
|
|
59
|
+
>(
|
|
60
|
+
parentCtx: TCtx,
|
|
61
|
+
overrides: ForkCtxOverrides = {},
|
|
62
|
+
reset: readonly ForkCtxResetKey[] = DEFAULT_RESET_KEYS
|
|
63
|
+
): TCtx => {
|
|
64
|
+
const forked: TCtx = { ...parentCtx };
|
|
65
|
+
for (const key of reset) {
|
|
66
|
+
forked[key as keyof TCtx] = undefined as TCtx[keyof TCtx];
|
|
67
|
+
}
|
|
68
|
+
return { ...forked, ...overrides };
|
|
69
|
+
};
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared, surface-agnostic helpers for rendering typed layer `input` schemas
|
|
3
|
+
* onto a surface's native idiom.
|
|
4
|
+
*
|
|
5
|
+
* Layer rendering has two halves:
|
|
6
|
+
* 1. **Collection** — walk the trail's effective layers (topo → surface →
|
|
7
|
+
* trail) and keep only the ones that declare an `input` schema. These
|
|
8
|
+
* are the layers a surface needs to render.
|
|
9
|
+
* 2. **Naming/collision policy** — when a layer field's rendered name
|
|
10
|
+
* collides with a name already claimed by the trail, by another layer,
|
|
11
|
+
* or by a surface-reserved name, the field is renamed using the
|
|
12
|
+
* deterministic `<layerName>-<originalField>` rule. The collision-detection
|
|
13
|
+
* logic itself is surface-agnostic; the *shape* of the rendered name
|
|
14
|
+
* (kebab-case CLI flag, camelCase MCP parameter, HTTP request field)
|
|
15
|
+
* stays per-surface.
|
|
16
|
+
*
|
|
17
|
+
* This module owns the surface-agnostic half. CLI/MCP/HTTP each layer their
|
|
18
|
+
* own rendering on top: see `@ontrails/cli/build`, `@ontrails/mcp/build`,
|
|
19
|
+
* and `@ontrails/http/build`.
|
|
20
|
+
*
|
|
21
|
+
* @see TRL-473 for the CLI rendering that introduced this contract.
|
|
22
|
+
* @see TRL-474 for the MCP and HTTP renderings that lifted these helpers.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import type { Layer } from './layer.js';
|
|
26
|
+
import type { Topo } from './topo.js';
|
|
27
|
+
import type { AnyTrail } from './trail.js';
|
|
28
|
+
|
|
29
|
+
export const LAYER_FIELD_RESERVED_NAMES: ReadonlySet<string> = new Set([
|
|
30
|
+
'all',
|
|
31
|
+
'devPermit',
|
|
32
|
+
'dryRun',
|
|
33
|
+
'input',
|
|
34
|
+
'inputJson',
|
|
35
|
+
'json',
|
|
36
|
+
'jsonl',
|
|
37
|
+
'output',
|
|
38
|
+
'permit',
|
|
39
|
+
'quiet',
|
|
40
|
+
'token',
|
|
41
|
+
'trailVersion',
|
|
42
|
+
'trace',
|
|
43
|
+
'watch',
|
|
44
|
+
]);
|
|
45
|
+
|
|
46
|
+
const toKebabCase = (name: string): string =>
|
|
47
|
+
name.replaceAll(/[A-Z]/g, (ch) => `-${ch.toLowerCase()}`);
|
|
48
|
+
|
|
49
|
+
export const LAYER_FIELD_RESERVED_NAMES_KEBAB: ReadonlySet<string> = new Set(
|
|
50
|
+
[...LAYER_FIELD_RESERVED_NAMES].map(toKebabCase)
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
// Collection
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Source of a typed layer attached to a trail.
|
|
59
|
+
*
|
|
60
|
+
* Surfaces may want to know whether a layer came from topo-, surface-, or
|
|
61
|
+
* trail-scope (e.g. for descriptive errors). The collection helper preserves
|
|
62
|
+
* this information so callers don't have to recompute it.
|
|
63
|
+
*/
|
|
64
|
+
export type AttachedLayerScope = 'topo' | 'surface' | 'trail';
|
|
65
|
+
|
|
66
|
+
export interface AttachedTypedLayer {
|
|
67
|
+
readonly layer: Layer;
|
|
68
|
+
readonly scope: AttachedLayerScope;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Collect every typed layer attached to a trail in the same composition order
|
|
73
|
+
* the executor uses (topo → surface → trail). Layers without an `input`
|
|
74
|
+
* schema are skipped — they have nothing to render onto a surface.
|
|
75
|
+
*
|
|
76
|
+
* @param graph - The topo carrying topo-scope layers.
|
|
77
|
+
* @param trail - The trail whose effective layers we are rendering.
|
|
78
|
+
* @param surfaceLayers - Layers attached at surface scope (`options.layers`
|
|
79
|
+
* on the surface builder).
|
|
80
|
+
*/
|
|
81
|
+
export const collectAttachedTypedLayers = (
|
|
82
|
+
graph: Topo,
|
|
83
|
+
trail: AnyTrail,
|
|
84
|
+
surfaceLayers?: readonly Layer[] | undefined
|
|
85
|
+
): readonly AttachedTypedLayer[] => {
|
|
86
|
+
const layers: AttachedTypedLayer[] = [];
|
|
87
|
+
for (const layer of graph.layers) {
|
|
88
|
+
if (layer.input !== undefined) {
|
|
89
|
+
layers.push({ layer, scope: 'topo' });
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
if (surfaceLayers !== undefined) {
|
|
93
|
+
for (const layer of surfaceLayers) {
|
|
94
|
+
if (layer.input !== undefined) {
|
|
95
|
+
layers.push({ layer, scope: 'surface' });
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
for (const layer of trail.layers) {
|
|
100
|
+
if (layer.input !== undefined) {
|
|
101
|
+
layers.push({ layer, scope: 'trail' });
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return layers;
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
// ---------------------------------------------------------------------------
|
|
108
|
+
// Collision rename rule
|
|
109
|
+
// ---------------------------------------------------------------------------
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Reason a layer field was renamed during rendering.
|
|
113
|
+
*
|
|
114
|
+
* Surfaces map this onto their own warning/error idiom. CLI emits a stderr
|
|
115
|
+
* warning, while MCP/HTTP rely on the rendered schema as the source of truth.
|
|
116
|
+
*/
|
|
117
|
+
export type LayerFieldRenameReason = 'reserved-name' | 'flag-collision';
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Outcome of rendering a single layer field's name.
|
|
121
|
+
*
|
|
122
|
+
* `claimedName` is the name the surface will publish to consumers; it is the
|
|
123
|
+
* field name when no collision was detected, or `<layerName>-<fieldName>` (or
|
|
124
|
+
* an analogous transformed form, depending on the surface's convention) when
|
|
125
|
+
* a collision required a rename. `routingTarget` is the original field name
|
|
126
|
+
* the value should be assigned back to inside the layer's runtime input.
|
|
127
|
+
*/
|
|
128
|
+
export interface LayerFieldRendering {
|
|
129
|
+
readonly claimedName: string;
|
|
130
|
+
readonly routingTarget: string;
|
|
131
|
+
readonly renamed: false;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export interface RenamedLayerFieldRendering {
|
|
135
|
+
readonly claimedName: string;
|
|
136
|
+
readonly routingTarget: string;
|
|
137
|
+
readonly renamed: true;
|
|
138
|
+
readonly originalName: string;
|
|
139
|
+
readonly reason: LayerFieldRenameReason;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export type RenderedLayerField =
|
|
143
|
+
| LayerFieldRendering
|
|
144
|
+
| RenamedLayerFieldRendering;
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Apply the deterministic collision rename rule to a single rendered layer
|
|
148
|
+
* field name.
|
|
149
|
+
*
|
|
150
|
+
* @param layerName - The layer's logical name (used as the rename prefix).
|
|
151
|
+
* @param originalName - The layer field's name as authored on its schema.
|
|
152
|
+
* @param renderedName - The candidate name in the surface's native idiom
|
|
153
|
+
* (e.g. kebab-case for CLI, camelCase for MCP, request field for HTTP).
|
|
154
|
+
* @param renamedName - The fallback name applied when a collision is detected.
|
|
155
|
+
* Surfaces compute this with their own casing rule.
|
|
156
|
+
* @param claimedNames - Names already taken by the trail's input or by
|
|
157
|
+
* previous layer renderings. Updated in place when a name is claimed.
|
|
158
|
+
* @param reservedNames - Framework-owned names that force a rename across
|
|
159
|
+
* surface renderings.
|
|
160
|
+
*/
|
|
161
|
+
export const renderLayerFieldName = (
|
|
162
|
+
_layerName: string,
|
|
163
|
+
originalName: string,
|
|
164
|
+
renderedName: string,
|
|
165
|
+
renamedName: string,
|
|
166
|
+
claimedNames: Set<string>,
|
|
167
|
+
reservedNames: ReadonlySet<string>
|
|
168
|
+
): RenderedLayerField => {
|
|
169
|
+
const collidesWithClaimed = claimedNames.has(renderedName);
|
|
170
|
+
const collidesWithReserved = reservedNames.has(renderedName);
|
|
171
|
+
|
|
172
|
+
if (!collidesWithClaimed && !collidesWithReserved) {
|
|
173
|
+
claimedNames.add(renderedName);
|
|
174
|
+
return {
|
|
175
|
+
claimedName: renderedName,
|
|
176
|
+
renamed: false,
|
|
177
|
+
routingTarget: originalName,
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
let claimedName = renamedName;
|
|
182
|
+
for (let suffix = 2; claimedNames.has(claimedName); suffix += 1) {
|
|
183
|
+
claimedName = `${renamedName}${suffix}`;
|
|
184
|
+
}
|
|
185
|
+
claimedNames.add(claimedName);
|
|
186
|
+
return {
|
|
187
|
+
claimedName,
|
|
188
|
+
originalName: renderedName,
|
|
189
|
+
reason: collidesWithReserved ? 'reserved-name' : 'flag-collision',
|
|
190
|
+
renamed: true,
|
|
191
|
+
routingTarget: originalName,
|
|
192
|
+
};
|
|
193
|
+
};
|
package/src/layer.ts
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import type { z } from 'zod';
|
|
2
|
+
|
|
3
|
+
import type { AnyTrail } from './trail.js';
|
|
4
|
+
import type { Implementation } from './types.js';
|
|
5
|
+
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
// Layer interface
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* A composable, named layer that wraps implementations.
|
|
12
|
+
*
|
|
13
|
+
* Layers attach at trail, surface, or topo scope and may declare an object
|
|
14
|
+
* `input` schema describing the configuration they need from the surrounding
|
|
15
|
+
* surface. Surface packages (CLI, MCP, HTTP) render this schema onto their
|
|
16
|
+
* native idioms — flags, tool parameters, query strings — alongside the
|
|
17
|
+
* trail's own input schema.
|
|
18
|
+
*
|
|
19
|
+
* @remarks
|
|
20
|
+
* The `input` schema is metadata for surface rendering. It must be an object
|
|
21
|
+
* schema so every surface can render named fields consistently. It is
|
|
22
|
+
* optional; layers without an `input` schema behave as plain wrappers. The
|
|
23
|
+
* layer's `wrap` function is the runtime contract.
|
|
24
|
+
*/
|
|
25
|
+
export type LayerInputSchema = z.ZodObject<z.ZodRawShape>;
|
|
26
|
+
|
|
27
|
+
export interface Layer {
|
|
28
|
+
readonly name: string;
|
|
29
|
+
readonly description?: string | undefined;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Authored configuration the layer needs from the surrounding surface.
|
|
33
|
+
*
|
|
34
|
+
* Surface packages render this schema onto their native idioms (CLI flags,
|
|
35
|
+
* MCP tool parameters, HTTP query strings) so a layer's input fields appear
|
|
36
|
+
* alongside the trail's own input fields. Optional — layers that wrap purely
|
|
37
|
+
* by behavior, with no surface-visible inputs, may omit it.
|
|
38
|
+
*
|
|
39
|
+
* @see TRL-473 for CLI flag rendering.
|
|
40
|
+
* @see TRL-474 for MCP and HTTP rendering.
|
|
41
|
+
*/
|
|
42
|
+
readonly input?: LayerInputSchema | undefined;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Wrap a trail's implementation, returning a new implementation.
|
|
46
|
+
*
|
|
47
|
+
* The trail is passed for metadata inspection (intent, schema, etc.).
|
|
48
|
+
* The implementation and return type are generic over the input/output
|
|
49
|
+
* types so layers remain type-safe when composed.
|
|
50
|
+
*/
|
|
51
|
+
wrap<I, O>(
|
|
52
|
+
trail: AnyTrail,
|
|
53
|
+
implementation: Implementation<I, O>
|
|
54
|
+
): Implementation<I, O>;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
// Composition
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Apply layers outermost-first: layers[0] wraps layers[1] wraps ... wraps
|
|
63
|
+
* the base implementation.
|
|
64
|
+
*
|
|
65
|
+
* An empty layers array returns the implementation unchanged.
|
|
66
|
+
*/
|
|
67
|
+
export const composeLayers = <I, O>(
|
|
68
|
+
layers: readonly Layer[],
|
|
69
|
+
trail: AnyTrail,
|
|
70
|
+
implementation: Implementation<I, O>
|
|
71
|
+
): Implementation<I, O> => {
|
|
72
|
+
// Fold right so layers[0] is the outermost wrapper.
|
|
73
|
+
let result = implementation;
|
|
74
|
+
for (let i = layers.length - 1; i >= 0; i -= 1) {
|
|
75
|
+
const layer = layers[i];
|
|
76
|
+
if (layer) {
|
|
77
|
+
result = layer.wrap(trail, result);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return result;
|
|
81
|
+
};
|
package/src/observe.ts
ADDED
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
import { ValidationError } from './errors.js';
|
|
2
|
+
import type { TraceSink } from './tracing.js';
|
|
3
|
+
import type { Layer } from './layer.js';
|
|
4
|
+
import { safeStringify } from './serialization.js';
|
|
5
|
+
import type { Logger, LogLevel, LogRecord, LogSink } from './types.js';
|
|
6
|
+
|
|
7
|
+
export interface ObserveConfig {
|
|
8
|
+
readonly log?: Logger | LogSink | undefined;
|
|
9
|
+
readonly trace?: TraceSink | undefined;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface ObserveCapabilities {
|
|
13
|
+
readonly log?: true | undefined;
|
|
14
|
+
readonly trace?: true | undefined;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface ObserveCapable {
|
|
18
|
+
readonly observes?: ObserveCapabilities | undefined;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export type ObserveInput = Logger | LogSink | TraceSink | ObserveConfig;
|
|
22
|
+
|
|
23
|
+
export interface TopoOptions {
|
|
24
|
+
readonly observe?: ObserveInput | undefined;
|
|
25
|
+
/**
|
|
26
|
+
* Typed layers attached at topo scope.
|
|
27
|
+
*
|
|
28
|
+
* Layers declared here wrap every trail invoked through this topo, on every
|
|
29
|
+
* surface. The execution pipeline composes topo-scope layers outermost —
|
|
30
|
+
* around surface-scope and trail-scope layers — so the final order is
|
|
31
|
+
* `topo → surface → trail → implementation` (outermost-first).
|
|
32
|
+
*/
|
|
33
|
+
readonly layers?: readonly Layer[] | undefined;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const OBSERVE_CONFIG_KEYS = new Set(['log', 'trace']);
|
|
37
|
+
export const OBSERVE_LOGGER_CONTEXT_KEY = '__trails_observe_logger';
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Context extension key that carries metadata accumulated on the observe
|
|
41
|
+
* logger across rebindings (e.g. signal fan-out metadata such as `consumerId`
|
|
42
|
+
* and `signalId`). When `applyTopoObserveContext` rebinds the logger for a
|
|
43
|
+
* new trail, it merges this metadata into the freshly built observe logger
|
|
44
|
+
* so consumer log records retain provenance back to the triggering signal.
|
|
45
|
+
*/
|
|
46
|
+
export const OBSERVE_LOGGER_METADATA_KEY = '__trails_observe_logger_metadata';
|
|
47
|
+
|
|
48
|
+
const isObject = (value: unknown): value is Record<string, unknown> =>
|
|
49
|
+
typeof value === 'object' && value !== null;
|
|
50
|
+
|
|
51
|
+
const hasFunction = (value: Record<string, unknown>, key: string): boolean =>
|
|
52
|
+
typeof value[key] === 'function';
|
|
53
|
+
|
|
54
|
+
export const isLogger = (value: unknown): value is Logger =>
|
|
55
|
+
isObject(value) &&
|
|
56
|
+
hasFunction(value, 'child') &&
|
|
57
|
+
hasFunction(value, 'debug') &&
|
|
58
|
+
hasFunction(value, 'error') &&
|
|
59
|
+
hasFunction(value, 'fatal') &&
|
|
60
|
+
hasFunction(value, 'info') &&
|
|
61
|
+
hasFunction(value, 'trace') &&
|
|
62
|
+
hasFunction(value, 'warn');
|
|
63
|
+
|
|
64
|
+
export const isLogSink = (value: unknown): value is LogSink =>
|
|
65
|
+
isObject(value) &&
|
|
66
|
+
typeof value['name'] === 'string' &&
|
|
67
|
+
hasFunction(value, 'write');
|
|
68
|
+
|
|
69
|
+
const readObserveCapabilities = (
|
|
70
|
+
value: unknown
|
|
71
|
+
): ObserveCapabilities | undefined => {
|
|
72
|
+
if (!isObject(value)) {
|
|
73
|
+
return undefined;
|
|
74
|
+
}
|
|
75
|
+
const capabilities = (value as ObserveCapable).observes;
|
|
76
|
+
if (!isObject(capabilities)) {
|
|
77
|
+
return undefined;
|
|
78
|
+
}
|
|
79
|
+
const log = capabilities['log'] === true;
|
|
80
|
+
const trace = capabilities['trace'] === true;
|
|
81
|
+
if (!log && !trace) {
|
|
82
|
+
return undefined;
|
|
83
|
+
}
|
|
84
|
+
return Object.freeze({
|
|
85
|
+
...(log ? { log: true as const } : {}),
|
|
86
|
+
...(trace ? { trace: true as const } : {}),
|
|
87
|
+
});
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
export const isTraceSink = (value: unknown): value is TraceSink =>
|
|
91
|
+
isObject(value) && hasFunction(value, 'write');
|
|
92
|
+
|
|
93
|
+
const isObserveConfigShape = (value: unknown): value is ObserveConfig => {
|
|
94
|
+
if (!isObject(value)) {
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
const keys = Object.keys(value);
|
|
98
|
+
return (
|
|
99
|
+
keys.length > 0 &&
|
|
100
|
+
keys.every((key) => OBSERVE_CONFIG_KEYS.has(key)) &&
|
|
101
|
+
('log' in value || 'trace' in value)
|
|
102
|
+
);
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Type guard for the `ObserveInput` union. Returns `true` only for shapes
|
|
107
|
+
* that {@link normalizeObserve} will accept without throwing — i.e. a
|
|
108
|
+
* `Logger`, an explicit `ObserveConfig`, or a `TraceSink`. Capability-only
|
|
109
|
+
* payloads (`{ observes: { trace: true } }` without an accompanying
|
|
110
|
+
* `write` method) and bare `LogSink` shorthand are intentionally rejected:
|
|
111
|
+
* the former is metadata about a missing implementation, and the latter
|
|
112
|
+
* is ambiguous between a `LogSink` and a `TraceSink` and is rejected by
|
|
113
|
+
* `normalizeObserve` accordingly. Keeping the guard tighter than the
|
|
114
|
+
* runtime accepts would let callers narrow to `ObserveInput` and then
|
|
115
|
+
* see `normalizeObserve` throw at runtime.
|
|
116
|
+
*/
|
|
117
|
+
export const isObserveInput = (
|
|
118
|
+
value: unknown
|
|
119
|
+
): value is ObserveInput | undefined => {
|
|
120
|
+
if (value === undefined) {
|
|
121
|
+
return true;
|
|
122
|
+
}
|
|
123
|
+
if (isLogger(value)) {
|
|
124
|
+
return true;
|
|
125
|
+
}
|
|
126
|
+
if (isObserveConfigShape(value)) {
|
|
127
|
+
return true;
|
|
128
|
+
}
|
|
129
|
+
if (isTraceSink(value) && !isLogSink(value)) {
|
|
130
|
+
// A bare TraceSink (no `name`) is unambiguous — `normalizeObserve`
|
|
131
|
+
// accepts it via the `isTraceSink` fallthrough. A LogSink shape is
|
|
132
|
+
// ambiguous (matches both guards) and would be rejected by
|
|
133
|
+
// `normalizeObserve`, so the guard rejects it here too.
|
|
134
|
+
return true;
|
|
135
|
+
}
|
|
136
|
+
return false;
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Returns true when `value` carries explicit observe capabilities via the
|
|
141
|
+
* `observes` discriminator. Used by the topo classifier to distinguish
|
|
142
|
+
* an `ObserveCapable` sink (clearly options) from a bare sink (ambiguous
|
|
143
|
+
* with a module export named `observe`).
|
|
144
|
+
*/
|
|
145
|
+
export const hasObserveCapabilities = (value: unknown): boolean =>
|
|
146
|
+
readObserveCapabilities(value) !== undefined;
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Returns true when `value` is shaped like the explicit `{ log?, trace? }`
|
|
150
|
+
* `ObserveConfig` payload. Exposed for the topo classifier so a config-style
|
|
151
|
+
* trailing argument is unambiguously classified as options.
|
|
152
|
+
*/
|
|
153
|
+
export const isObserveConfig = (value: unknown): value is ObserveConfig =>
|
|
154
|
+
isObserveConfigShape(value);
|
|
155
|
+
|
|
156
|
+
const normalizeLogTarget = (
|
|
157
|
+
target: ObserveConfig['log']
|
|
158
|
+
): ObserveConfig['log'] => {
|
|
159
|
+
if (target === undefined || isLogger(target) || isLogSink(target)) {
|
|
160
|
+
return target;
|
|
161
|
+
}
|
|
162
|
+
throw new ValidationError('topo observe.log must be a Logger or LogSink');
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
const normalizeTraceTarget = (
|
|
166
|
+
target: ObserveConfig['trace']
|
|
167
|
+
): ObserveConfig['trace'] => {
|
|
168
|
+
if (target === undefined || isTraceSink(target)) {
|
|
169
|
+
return target;
|
|
170
|
+
}
|
|
171
|
+
throw new ValidationError('topo observe.trace must be a TraceSink');
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Maps observe log levels to the `console` method that should receive the
|
|
176
|
+
* formatted record. `silent` is intentionally absent — records at that level
|
|
177
|
+
* are dropped before reaching `console`.
|
|
178
|
+
*/
|
|
179
|
+
const DEFAULT_SINK_CONSOLE_METHOD: Record<
|
|
180
|
+
LogLevel,
|
|
181
|
+
'debug' | 'error' | 'info' | 'warn' | undefined
|
|
182
|
+
> = {
|
|
183
|
+
debug: 'debug',
|
|
184
|
+
error: 'error',
|
|
185
|
+
fatal: 'error',
|
|
186
|
+
info: 'info',
|
|
187
|
+
silent: undefined,
|
|
188
|
+
trace: 'debug',
|
|
189
|
+
warn: 'warn',
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
const stringifyDefaultConsoleRecord = (record: LogRecord): string => {
|
|
193
|
+
const serialized = safeStringify({
|
|
194
|
+
category: record.category,
|
|
195
|
+
level: record.level,
|
|
196
|
+
message: record.message,
|
|
197
|
+
metadata: record.metadata,
|
|
198
|
+
timestamp: record.timestamp.toISOString(),
|
|
199
|
+
});
|
|
200
|
+
if (serialized.isOk()) {
|
|
201
|
+
return serialized.value;
|
|
202
|
+
}
|
|
203
|
+
return JSON.stringify({
|
|
204
|
+
category: record.category,
|
|
205
|
+
level: record.level,
|
|
206
|
+
message: record.message,
|
|
207
|
+
metadata: '[unserializable]',
|
|
208
|
+
timestamp: record.timestamp.toISOString(),
|
|
209
|
+
});
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* In-core mirror of `@ontrails/observability`'s `createConsoleSink` shape, kept
|
|
214
|
+
* minimal and private to avoid a reverse dependency from `@ontrails/core`
|
|
215
|
+
* onto `@ontrails/observability`. It mirrors the console level mapping in
|
|
216
|
+
* `packages/observability/src/sinks.ts:50` and emits each record as a single-line
|
|
217
|
+
* JSON object written to the matching `console.{debug|info|warn|error}` method.
|
|
218
|
+
*
|
|
219
|
+
* @remarks
|
|
220
|
+
* Used as the default `observe.log` target when `topo()` is called without an
|
|
221
|
+
* explicit `observe` option, so every app gets a non-null `ctx.logger` with
|
|
222
|
+
* structured stdout output and zero configuration. Apps that want richer
|
|
223
|
+
* formatting, file destinations, or custom sink behavior should pass an explicit
|
|
224
|
+
* `observe` option, which fully replaces this default.
|
|
225
|
+
*/
|
|
226
|
+
const createDefaultConsoleSink = (): LogSink => ({
|
|
227
|
+
name: 'console',
|
|
228
|
+
write(record): void {
|
|
229
|
+
const method = DEFAULT_SINK_CONSOLE_METHOD[record.level];
|
|
230
|
+
if (method === undefined) {
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
const payload = stringifyDefaultConsoleRecord(record);
|
|
234
|
+
// oxlint-disable-next-line trails-local/no-console-in-packages -- ADR 0041 mandates a default console logger in core; this is the single sanctioned console boundary, mirroring `@ontrails/observability`'s `createConsoleSink`.
|
|
235
|
+
console[method](payload);
|
|
236
|
+
},
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* The default observe configuration applied when `topo()` receives no
|
|
241
|
+
* `observe` option. Frozen so callers cannot mutate the shared default; the
|
|
242
|
+
* sink itself is shared across topos because it has no per-topo state.
|
|
243
|
+
*/
|
|
244
|
+
const DEFAULT_OBSERVE_CONFIG: ObserveConfig = Object.freeze({
|
|
245
|
+
log: createDefaultConsoleSink(),
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
export const normalizeObserve = (
|
|
249
|
+
observe: ObserveInput | undefined
|
|
250
|
+
): ObserveConfig | undefined => {
|
|
251
|
+
if (observe === undefined) {
|
|
252
|
+
// ADR 0041 promises a non-null `ctx.logger` with zero configuration.
|
|
253
|
+
// Returning the default config here lets the existing topo → adapter
|
|
254
|
+
// path renders this log sink into `ctx.logger` without a second
|
|
255
|
+
// resolution point or a reverse dependency on `@ontrails/observability`.
|
|
256
|
+
return DEFAULT_OBSERVE_CONFIG;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const capabilities = readObserveCapabilities(observe);
|
|
260
|
+
if (capabilities !== undefined) {
|
|
261
|
+
const log =
|
|
262
|
+
capabilities.log === true
|
|
263
|
+
? normalizeLogTarget(observe as Logger | LogSink)
|
|
264
|
+
: undefined;
|
|
265
|
+
const trace =
|
|
266
|
+
capabilities.trace === true
|
|
267
|
+
? normalizeTraceTarget(observe as TraceSink)
|
|
268
|
+
: undefined;
|
|
269
|
+
return Object.freeze({
|
|
270
|
+
...(log === undefined ? {} : { log }),
|
|
271
|
+
...(trace === undefined ? {} : { trace }),
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
if (isLogger(observe)) {
|
|
276
|
+
return Object.freeze({ log: observe });
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
if (isObserveConfigShape(observe)) {
|
|
280
|
+
const log = normalizeLogTarget(observe.log);
|
|
281
|
+
const trace = normalizeTraceTarget(observe.trace);
|
|
282
|
+
if (log === undefined && trace === undefined) {
|
|
283
|
+
return undefined;
|
|
284
|
+
}
|
|
285
|
+
return Object.freeze({
|
|
286
|
+
...(log === undefined ? {} : { log }),
|
|
287
|
+
...(trace === undefined ? {} : { trace }),
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
if (isLogSink(observe)) {
|
|
292
|
+
throw new ValidationError(
|
|
293
|
+
'topo observe shorthand is ambiguous for named sinks; use { log: sink } or { trace: sink }'
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
if (isTraceSink(observe)) {
|
|
298
|
+
return Object.freeze({ trace: observe });
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
throw new ValidationError(
|
|
302
|
+
'topo observe must be a Logger, LogSink, TraceSink, or { log, trace } object'
|
|
303
|
+
);
|
|
304
|
+
};
|
|
305
|
+
|
|
306
|
+
const createLogSinkLogger = (
|
|
307
|
+
sink: LogSink,
|
|
308
|
+
category: string,
|
|
309
|
+
baseMetadata: Record<string, unknown>
|
|
310
|
+
): Logger => {
|
|
311
|
+
const write = (
|
|
312
|
+
level: LogLevel,
|
|
313
|
+
message: string,
|
|
314
|
+
metadata?: Record<string, unknown>
|
|
315
|
+
): void => {
|
|
316
|
+
sink.write({
|
|
317
|
+
category,
|
|
318
|
+
level,
|
|
319
|
+
message,
|
|
320
|
+
metadata: { ...baseMetadata, ...metadata },
|
|
321
|
+
timestamp: new Date(),
|
|
322
|
+
});
|
|
323
|
+
};
|
|
324
|
+
|
|
325
|
+
return {
|
|
326
|
+
child(metadata: Record<string, unknown>): Logger {
|
|
327
|
+
return createLogSinkLogger(sink, category, {
|
|
328
|
+
...baseMetadata,
|
|
329
|
+
...metadata,
|
|
330
|
+
});
|
|
331
|
+
},
|
|
332
|
+
debug(message, metadata): void {
|
|
333
|
+
write('debug', message, metadata);
|
|
334
|
+
},
|
|
335
|
+
error(message, metadata): void {
|
|
336
|
+
write('error', message, metadata);
|
|
337
|
+
},
|
|
338
|
+
fatal(message, metadata): void {
|
|
339
|
+
write('fatal', message, metadata);
|
|
340
|
+
},
|
|
341
|
+
info(message, metadata): void {
|
|
342
|
+
write('info', message, metadata);
|
|
343
|
+
},
|
|
344
|
+
name: category,
|
|
345
|
+
trace(message, metadata): void {
|
|
346
|
+
write('trace', message, metadata);
|
|
347
|
+
},
|
|
348
|
+
warn(message, metadata): void {
|
|
349
|
+
write('warn', message, metadata);
|
|
350
|
+
},
|
|
351
|
+
};
|
|
352
|
+
};
|
|
353
|
+
|
|
354
|
+
export const createObserveLogger = (
|
|
355
|
+
log: Logger | LogSink,
|
|
356
|
+
category: string,
|
|
357
|
+
metadata: Record<string, unknown>
|
|
358
|
+
): Logger =>
|
|
359
|
+
isLogger(log)
|
|
360
|
+
? log.child(metadata)
|
|
361
|
+
: createLogSinkLogger(log, category, metadata);
|