@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,694 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The app-authored `surfaces` overlay.
|
|
3
|
+
*
|
|
4
|
+
* `surfaceOverlay()` authors named bindings from a surface's namespace onto
|
|
5
|
+
* trails — the shared vocabulary that subsumes CLI aliases and MCP trailheads.
|
|
6
|
+
* A scalar binding is a synonym for one trail selector; a list binding is a
|
|
7
|
+
* grouped entry over several selectors. The overlay lands in `trails.lock`
|
|
8
|
+
* under the well-known `surfaces` namespace, and consumption helpers enforce
|
|
9
|
+
* the provenance boundary: surfaces obey app-authored overlays only, so an
|
|
10
|
+
* adapter can never inject a binding a surface obeys.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { z } from 'zod';
|
|
14
|
+
|
|
15
|
+
import type { CliCommandAliasInput } from './derive.js';
|
|
16
|
+
import { ValidationError } from './errors.js';
|
|
17
|
+
import type { Topo } from './topo.js';
|
|
18
|
+
import { matchesTrailIdGlob } from './trail-id-glob.js';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The well-known lock overlay namespace owned by app-authored surface
|
|
22
|
+
* bindings.
|
|
23
|
+
*
|
|
24
|
+
* @example
|
|
25
|
+
* ```ts
|
|
26
|
+
* import { SURFACES_OVERLAY_NAMESPACE } from '@ontrails/core';
|
|
27
|
+
*
|
|
28
|
+
* const facts = lock.topoGraph.overlays?.[SURFACES_OVERLAY_NAMESPACE];
|
|
29
|
+
* ```
|
|
30
|
+
*/
|
|
31
|
+
export const SURFACES_OVERLAY_NAMESPACE = 'surfaces' as const;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Who authored an overlay envelope.
|
|
35
|
+
*
|
|
36
|
+
* `'adapter-derived'` marks facts an adapter renders from the topo;
|
|
37
|
+
* `'app-authored'` marks bindings the app wrote by hand. Surfaces obey
|
|
38
|
+
* app-authored overlays only — adapters contribute facts, never bindings.
|
|
39
|
+
*
|
|
40
|
+
* @example
|
|
41
|
+
* ```ts
|
|
42
|
+
* import type { OverlayProvenance } from '@ontrails/core';
|
|
43
|
+
*
|
|
44
|
+
* const provenance: OverlayProvenance = 'app-authored';
|
|
45
|
+
* ```
|
|
46
|
+
*/
|
|
47
|
+
export type OverlayProvenance = 'adapter-derived' | 'app-authored';
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* One trail selector inside a surface binding: an exact trail id
|
|
51
|
+
* (`'gear.list'`) or a dotted trail-id glob (`'snippet.*'`).
|
|
52
|
+
*
|
|
53
|
+
* @example
|
|
54
|
+
* ```ts
|
|
55
|
+
* import type { SurfaceBindingRef } from '@ontrails/core';
|
|
56
|
+
*
|
|
57
|
+
* const exact: SurfaceBindingRef = 'gear.list';
|
|
58
|
+
* const glob: SurfaceBindingRef = 'snippet.*';
|
|
59
|
+
* ```
|
|
60
|
+
*/
|
|
61
|
+
export type SurfaceBindingRef = string;
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The authored value of one surface binding. A scalar ref is a synonym for
|
|
65
|
+
* one trail selector; a list of refs is a grouped entry. Value shape is the
|
|
66
|
+
* discriminator — a singleton list is still a group.
|
|
67
|
+
*
|
|
68
|
+
* @example
|
|
69
|
+
* ```ts
|
|
70
|
+
* import type { SurfaceBindingValue } from '@ontrails/core';
|
|
71
|
+
*
|
|
72
|
+
* const synonym: SurfaceBindingValue = 'gear.list';
|
|
73
|
+
* const group: SurfaceBindingValue = ['gear.create', 'gear.list'];
|
|
74
|
+
* ```
|
|
75
|
+
*/
|
|
76
|
+
export type SurfaceBindingValue =
|
|
77
|
+
| SurfaceBindingRef
|
|
78
|
+
| readonly SurfaceBindingRef[];
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Named bindings for one surface: binding name to trail selector(s).
|
|
82
|
+
*
|
|
83
|
+
* @example
|
|
84
|
+
* ```ts
|
|
85
|
+
* import type { SurfaceBindings } from '@ontrails/core';
|
|
86
|
+
*
|
|
87
|
+
* const cli: SurfaceBindings = {
|
|
88
|
+
* gear: ['gear.create', 'gear.list'],
|
|
89
|
+
* ls: 'gear.list',
|
|
90
|
+
* };
|
|
91
|
+
* ```
|
|
92
|
+
*/
|
|
93
|
+
export type SurfaceBindings = Readonly<Record<string, SurfaceBindingValue>>;
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* The full `surfaces` overlay payload: per-surface binding maps keyed by the
|
|
97
|
+
* surfaces that can obey bindings today (`cli`, `http`, `mcp`, `ws`).
|
|
98
|
+
*
|
|
99
|
+
* @example
|
|
100
|
+
* ```ts
|
|
101
|
+
* import type { SurfaceOverlayBindings } from '@ontrails/core';
|
|
102
|
+
*
|
|
103
|
+
* const bindings: SurfaceOverlayBindings = {
|
|
104
|
+
* cli: { ls: 'gear.list' },
|
|
105
|
+
* mcp: { snippets: ['snippet.create', 'snippet.get'] },
|
|
106
|
+
* };
|
|
107
|
+
* ```
|
|
108
|
+
*/
|
|
109
|
+
export interface SurfaceOverlayBindings {
|
|
110
|
+
readonly cli?: SurfaceBindings | undefined;
|
|
111
|
+
readonly http?: SurfaceBindings | undefined;
|
|
112
|
+
readonly mcp?: SurfaceBindings | undefined;
|
|
113
|
+
readonly ws?: SurfaceBindings | undefined;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const surfaceBindingRefSchema = z.string().min(1);
|
|
117
|
+
|
|
118
|
+
const surfaceBindingValueSchema = z.union([
|
|
119
|
+
surfaceBindingRefSchema,
|
|
120
|
+
z.array(surfaceBindingRefSchema).min(1).readonly(),
|
|
121
|
+
]);
|
|
122
|
+
|
|
123
|
+
const surfaceBindingsRecordSchema = z.record(
|
|
124
|
+
z.string().min(1),
|
|
125
|
+
surfaceBindingValueSchema
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Schema for the `surfaces` overlay payload.
|
|
130
|
+
*
|
|
131
|
+
* Strict: only the `cli`, `http`, `mcp`, and `ws` surface keys are accepted.
|
|
132
|
+
* Each surface maps non-empty binding names to a non-empty selector string or
|
|
133
|
+
* a non-empty list of non-empty selector strings — a group with zero members
|
|
134
|
+
* is rejected because it promises an entry that binds nothing.
|
|
135
|
+
*
|
|
136
|
+
* @example
|
|
137
|
+
* ```ts
|
|
138
|
+
* import { surfaceOverlayBindingsSchema } from '@ontrails/core';
|
|
139
|
+
*
|
|
140
|
+
* const parsed = surfaceOverlayBindingsSchema.safeParse({
|
|
141
|
+
* cli: { ls: 'gear.list' },
|
|
142
|
+
* });
|
|
143
|
+
* // parsed.success === true
|
|
144
|
+
* ```
|
|
145
|
+
*/
|
|
146
|
+
export const surfaceOverlayBindingsSchema = z
|
|
147
|
+
.object({
|
|
148
|
+
cli: surfaceBindingsRecordSchema.optional(),
|
|
149
|
+
http: surfaceBindingsRecordSchema.optional(),
|
|
150
|
+
mcp: surfaceBindingsRecordSchema.optional(),
|
|
151
|
+
ws: surfaceBindingsRecordSchema.optional(),
|
|
152
|
+
})
|
|
153
|
+
.strict();
|
|
154
|
+
|
|
155
|
+
const summarizeIssues = (error: z.ZodError): string =>
|
|
156
|
+
error.issues
|
|
157
|
+
.map((issue) =>
|
|
158
|
+
issue.path.length === 0
|
|
159
|
+
? issue.message
|
|
160
|
+
: `${issue.path.map(String).join('.')}: ${issue.message}`
|
|
161
|
+
)
|
|
162
|
+
.join('; ');
|
|
163
|
+
|
|
164
|
+
const parseSurfaceOverlayBindings = (
|
|
165
|
+
value: unknown,
|
|
166
|
+
remediation: string
|
|
167
|
+
): SurfaceOverlayBindings => {
|
|
168
|
+
const parsed = surfaceOverlayBindingsSchema.safeParse(value);
|
|
169
|
+
if (!parsed.success) {
|
|
170
|
+
throw new ValidationError(
|
|
171
|
+
`The "${SURFACES_OVERLAY_NAMESPACE}" overlay bindings are invalid (${summarizeIssues(parsed.error)}). ${remediation}`
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
return parsed.data;
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* The classified shape of one surface binding value.
|
|
179
|
+
*
|
|
180
|
+
* Value shape is the discriminator, not cardinality: a scalar ref is a
|
|
181
|
+
* synonym, and any list — including a singleton list — is a group.
|
|
182
|
+
*
|
|
183
|
+
* @example
|
|
184
|
+
* ```ts
|
|
185
|
+
* import type { SurfaceBindingShape } from '@ontrails/core';
|
|
186
|
+
*
|
|
187
|
+
* const shape: SurfaceBindingShape = { kind: 'synonym', trail: 'gear.list' };
|
|
188
|
+
* ```
|
|
189
|
+
*/
|
|
190
|
+
export type SurfaceBindingShape =
|
|
191
|
+
| { readonly kind: 'synonym'; readonly trail: SurfaceBindingRef }
|
|
192
|
+
| { readonly kind: 'group'; readonly members: readonly SurfaceBindingRef[] };
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Classify a surface binding value by its authored shape.
|
|
196
|
+
*
|
|
197
|
+
* A scalar ref classifies as a synonym. A list classifies as a group — a
|
|
198
|
+
* singleton list stays a group, because value shape (not member count) is
|
|
199
|
+
* the discriminator between "another name for this trail" and "a grouped
|
|
200
|
+
* entry over these trails".
|
|
201
|
+
*
|
|
202
|
+
* @example
|
|
203
|
+
* ```ts
|
|
204
|
+
* import { classifySurfaceBinding } from '@ontrails/core';
|
|
205
|
+
*
|
|
206
|
+
* classifySurfaceBinding('gear.list');
|
|
207
|
+
* // => { kind: 'synonym', trail: 'gear.list' }
|
|
208
|
+
* classifySurfaceBinding(['gear.list']);
|
|
209
|
+
* // => { kind: 'group', members: ['gear.list'] }
|
|
210
|
+
* ```
|
|
211
|
+
*/
|
|
212
|
+
export const classifySurfaceBinding = (
|
|
213
|
+
value: SurfaceBindingValue
|
|
214
|
+
): SurfaceBindingShape =>
|
|
215
|
+
typeof value === 'string'
|
|
216
|
+
? { kind: 'synonym', trail: value }
|
|
217
|
+
: { kind: 'group', members: value };
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* The app-authored `surfaces` overlay envelope.
|
|
221
|
+
*
|
|
222
|
+
* Structurally compatible with the adapter-kit `Overlay` contract — the
|
|
223
|
+
* compile path collects it like any other overlay registration — while also
|
|
224
|
+
* exposing {@link SurfaceOverlay.bindings} for direct reads without a derive
|
|
225
|
+
* round-trip.
|
|
226
|
+
*
|
|
227
|
+
* @example
|
|
228
|
+
* ```ts
|
|
229
|
+
* import type { SurfaceOverlay } from '@ontrails/core';
|
|
230
|
+
* import { surfaceOverlay } from '@ontrails/core';
|
|
231
|
+
*
|
|
232
|
+
* const overlay: SurfaceOverlay = surfaceOverlay({
|
|
233
|
+
* cli: { ls: 'gear.list' },
|
|
234
|
+
* });
|
|
235
|
+
* ```
|
|
236
|
+
*/
|
|
237
|
+
export interface SurfaceOverlay {
|
|
238
|
+
/** Always the well-known `surfaces` namespace. */
|
|
239
|
+
readonly namespace: typeof SURFACES_OVERLAY_NAMESPACE;
|
|
240
|
+
/** Always app-authored — the provenance surfaces obey. */
|
|
241
|
+
readonly provenance: 'app-authored';
|
|
242
|
+
/** The bindings schema, enforced again on the compile path. */
|
|
243
|
+
readonly schema: z.ZodType;
|
|
244
|
+
/**
|
|
245
|
+
* Derive the overlay facts. Ignores the topo — bindings are authored —
|
|
246
|
+
* so the parameter is optional; the signature stays structurally
|
|
247
|
+
* assignable to the adapter-kit `Overlay` contract's `(topo) => unknown`.
|
|
248
|
+
*/
|
|
249
|
+
readonly derive: (topo?: Topo) => SurfaceOverlayBindings;
|
|
250
|
+
/** The validated bindings, for direct reads. */
|
|
251
|
+
readonly bindings: SurfaceOverlayBindings;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Author the `surfaces` overlay from per-surface bindings.
|
|
256
|
+
*
|
|
257
|
+
* Validates the bindings eagerly and throws a `ValidationError` with the
|
|
258
|
+
* schema issues when they are invalid, so a bad binding fails where it was
|
|
259
|
+
* authored instead of at compile time. The returned envelope's `derive`
|
|
260
|
+
* returns the validated bindings and ignores the topo.
|
|
261
|
+
*
|
|
262
|
+
* @example
|
|
263
|
+
* ```ts
|
|
264
|
+
* import { surfaceOverlay } from '@ontrails/core';
|
|
265
|
+
*
|
|
266
|
+
* export const trailsOverlays = [
|
|
267
|
+
* surfaceOverlay({
|
|
268
|
+
* cli: { gear: ['gear.create', 'gear.list'], ls: 'gear.list' },
|
|
269
|
+
* mcp: { snippets: ['snippet.create', 'snippet.get', 'snippet.fork'] },
|
|
270
|
+
* }),
|
|
271
|
+
* ];
|
|
272
|
+
* ```
|
|
273
|
+
*/
|
|
274
|
+
export const surfaceOverlay = (
|
|
275
|
+
bindings: SurfaceOverlayBindings
|
|
276
|
+
): SurfaceOverlay => {
|
|
277
|
+
const validated = parseSurfaceOverlayBindings(
|
|
278
|
+
bindings,
|
|
279
|
+
'Fix the bindings passed to surfaceOverlay() and rerun `trails compile`.'
|
|
280
|
+
);
|
|
281
|
+
return {
|
|
282
|
+
bindings: validated,
|
|
283
|
+
derive: () => validated,
|
|
284
|
+
namespace: SURFACES_OVERLAY_NAMESPACE,
|
|
285
|
+
provenance: 'app-authored',
|
|
286
|
+
schema: surfaceOverlayBindingsSchema,
|
|
287
|
+
};
|
|
288
|
+
};
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* The minimal structural shape of an overlay envelope the surface-overlay
|
|
292
|
+
* consumption helpers can inspect.
|
|
293
|
+
*
|
|
294
|
+
* `derive` is declared with an optional topo because app-authored surface
|
|
295
|
+
* overlays derive independently of the topo; adapter envelopes whose derive
|
|
296
|
+
* requires the topo still match structurally, and the provenance gate
|
|
297
|
+
* rejects them before their derive is ever invoked.
|
|
298
|
+
*
|
|
299
|
+
* @example
|
|
300
|
+
* ```ts
|
|
301
|
+
* import type { OverlayEnvelopeLike } from '@ontrails/core';
|
|
302
|
+
* import { resolveSurfaceOverlayBindings } from '@ontrails/core';
|
|
303
|
+
*
|
|
304
|
+
* const bindings = resolveSurfaceOverlayBindings(
|
|
305
|
+
* trailsOverlays as readonly OverlayEnvelopeLike[]
|
|
306
|
+
* );
|
|
307
|
+
* ```
|
|
308
|
+
*/
|
|
309
|
+
export interface OverlayEnvelopeLike {
|
|
310
|
+
/** The lock overlay namespace the envelope owns. */
|
|
311
|
+
readonly namespace: string;
|
|
312
|
+
/** Who authored the envelope. Absent means adapter-derived. */
|
|
313
|
+
readonly provenance?: OverlayProvenance | undefined;
|
|
314
|
+
/** The envelope's fact schema. */
|
|
315
|
+
readonly schema: z.ZodType;
|
|
316
|
+
/** Derive the envelope's facts. */
|
|
317
|
+
derive(topo?: Topo): unknown;
|
|
318
|
+
/**
|
|
319
|
+
* Static surface bindings, when the envelope carries them directly.
|
|
320
|
+
*
|
|
321
|
+
* `surfaceOverlay()` envelopes expose their validated bindings here;
|
|
322
|
+
* consumption prefers this over invoking {@link derive}, so a `surfaces`
|
|
323
|
+
* envelope must never derive facts that differ from its authored bindings.
|
|
324
|
+
*/
|
|
325
|
+
readonly bindings?: unknown;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Resolve the `surfaces` bindings from a collection of overlay envelopes.
|
|
330
|
+
*
|
|
331
|
+
* This is the provenance boundary: surfaces obey app-authored overlays only —
|
|
332
|
+
* adapters contribute facts, never bindings. A `surfaces` envelope without
|
|
333
|
+
* `provenance: 'app-authored'` (absent provenance is adapter-derived) throws
|
|
334
|
+
* a `ValidationError` naming `surfaceOverlay()` as the fix, as does a
|
|
335
|
+
* duplicate `surfaces` namespace or a derive result that fails the bindings
|
|
336
|
+
* schema. Returns `undefined` when no `surfaces` envelope is present.
|
|
337
|
+
*
|
|
338
|
+
* @example
|
|
339
|
+
* ```ts
|
|
340
|
+
* import { resolveSurfaceOverlayBindings, surfaceOverlay } from '@ontrails/core';
|
|
341
|
+
*
|
|
342
|
+
* const bindings = resolveSurfaceOverlayBindings([
|
|
343
|
+
* surfaceOverlay({ cli: { ls: 'gear.list' } }),
|
|
344
|
+
* ]);
|
|
345
|
+
* // => { cli: { ls: 'gear.list' } }
|
|
346
|
+
* ```
|
|
347
|
+
*/
|
|
348
|
+
export const resolveSurfaceOverlayBindings = (
|
|
349
|
+
overlays: readonly OverlayEnvelopeLike[] | undefined
|
|
350
|
+
): SurfaceOverlayBindings | undefined => {
|
|
351
|
+
const matches = (overlays ?? []).filter(
|
|
352
|
+
(overlay) => overlay.namespace === SURFACES_OVERLAY_NAMESPACE
|
|
353
|
+
);
|
|
354
|
+
const [first] = matches;
|
|
355
|
+
if (first === undefined) {
|
|
356
|
+
return undefined;
|
|
357
|
+
}
|
|
358
|
+
if (matches.length > 1) {
|
|
359
|
+
throw new ValidationError(
|
|
360
|
+
`Duplicate "${SURFACES_OVERLAY_NAMESPACE}" overlay namespace. Author one surfaceOverlay() in the app module and remove the duplicate registration.`
|
|
361
|
+
);
|
|
362
|
+
}
|
|
363
|
+
if (first.provenance !== 'app-authored') {
|
|
364
|
+
throw new ValidationError(
|
|
365
|
+
`The "${SURFACES_OVERLAY_NAMESPACE}" namespace obeys app-authored overlays only — adapters contribute facts, never bindings. Author the bindings with surfaceOverlay() in the app module.`
|
|
366
|
+
);
|
|
367
|
+
}
|
|
368
|
+
return parseSurfaceOverlayBindings(
|
|
369
|
+
first.bindings ?? first.derive(),
|
|
370
|
+
'Author the bindings with surfaceOverlay() in the app module.'
|
|
371
|
+
);
|
|
372
|
+
};
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Per-trail CLI alias inputs expanded from the `surfaces` overlay's `cli`
|
|
376
|
+
* bindings: trail id to the absolute command paths that alias it.
|
|
377
|
+
*
|
|
378
|
+
* @example
|
|
379
|
+
* ```ts
|
|
380
|
+
* import type { CliSurfaceBindingAliases } from '@ontrails/core';
|
|
381
|
+
*
|
|
382
|
+
* const aliases: CliSurfaceBindingAliases = {
|
|
383
|
+
* 'gear.list': [['gear', 'ls']],
|
|
384
|
+
* };
|
|
385
|
+
* ```
|
|
386
|
+
*/
|
|
387
|
+
export type CliSurfaceBindingAliases = Readonly<
|
|
388
|
+
Record<string, readonly CliCommandAliasInput[]>
|
|
389
|
+
>;
|
|
390
|
+
|
|
391
|
+
const CLI_BINDING_FIX =
|
|
392
|
+
'Fix the binding in surfaceOverlay({ cli }) in the app module.';
|
|
393
|
+
|
|
394
|
+
const MCP_BINDING_FIX =
|
|
395
|
+
'Fix the binding in surfaceOverlay({ mcp }) in the app module.';
|
|
396
|
+
|
|
397
|
+
const assertCliBindingName = (name: string): readonly string[] => {
|
|
398
|
+
const segments = name.split('.');
|
|
399
|
+
if (segments.some((segment) => segment.length === 0 || /\s/.test(segment))) {
|
|
400
|
+
throw new ValidationError(
|
|
401
|
+
`The "${SURFACES_OVERLAY_NAMESPACE}" overlay cli binding "${name}" is not a valid command path — every dot-separated segment must be non-empty and contain no whitespace. ${CLI_BINDING_FIX}`
|
|
402
|
+
);
|
|
403
|
+
}
|
|
404
|
+
return segments;
|
|
405
|
+
};
|
|
406
|
+
|
|
407
|
+
const expandSelector = (
|
|
408
|
+
trailIds: readonly string[],
|
|
409
|
+
selector: SurfaceBindingRef
|
|
410
|
+
): readonly string[] =>
|
|
411
|
+
trailIds
|
|
412
|
+
.filter((trailId) => matchesTrailIdGlob(trailId, selector))
|
|
413
|
+
.toSorted();
|
|
414
|
+
|
|
415
|
+
const expandSynonymBinding = (
|
|
416
|
+
trailIds: readonly string[],
|
|
417
|
+
surfaceKey: string,
|
|
418
|
+
name: string,
|
|
419
|
+
selector: SurfaceBindingRef,
|
|
420
|
+
groupNoun: string,
|
|
421
|
+
fix: string
|
|
422
|
+
): string => {
|
|
423
|
+
const matches = expandSelector(trailIds, selector);
|
|
424
|
+
const [first] = matches;
|
|
425
|
+
if (first === undefined) {
|
|
426
|
+
throw new ValidationError(
|
|
427
|
+
`The "${SURFACES_OVERLAY_NAMESPACE}" overlay ${surfaceKey} binding "${name}" resolves to no trails: selector "${selector}" matched none. ${fix}`
|
|
428
|
+
);
|
|
429
|
+
}
|
|
430
|
+
if (matches.length > 1) {
|
|
431
|
+
throw new ValidationError(
|
|
432
|
+
`The "${SURFACES_OVERLAY_NAMESPACE}" overlay ${surfaceKey} binding "${name}" resolves to ${matches.length} trails (${matches.join(', ')}). A scalar binding is a synonym for exactly one trail — use a list value to expose a ${groupNoun} instead. ${fix}`
|
|
433
|
+
);
|
|
434
|
+
}
|
|
435
|
+
return first;
|
|
436
|
+
};
|
|
437
|
+
|
|
438
|
+
const expandGroupBinding = (
|
|
439
|
+
trailIds: readonly string[],
|
|
440
|
+
surfaceKey: string,
|
|
441
|
+
name: string,
|
|
442
|
+
selectors: readonly SurfaceBindingRef[],
|
|
443
|
+
fix: string
|
|
444
|
+
): readonly string[] => {
|
|
445
|
+
const members = [
|
|
446
|
+
...new Set(
|
|
447
|
+
selectors.flatMap((selector) => expandSelector(trailIds, selector))
|
|
448
|
+
),
|
|
449
|
+
].toSorted();
|
|
450
|
+
if (members.length === 0) {
|
|
451
|
+
throw new ValidationError(
|
|
452
|
+
`The "${SURFACES_OVERLAY_NAMESPACE}" overlay ${surfaceKey} group binding "${name}" resolves to no trails: selectors ${selectors.map((selector) => `"${selector}"`).join(', ')} matched none. ${fix}`
|
|
453
|
+
);
|
|
454
|
+
}
|
|
455
|
+
return members;
|
|
456
|
+
};
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* Expand the `surfaces` overlay's `cli` bindings into per-trail CLI alias
|
|
460
|
+
* inputs, validating every binding against the topo's trail ids.
|
|
461
|
+
*
|
|
462
|
+
* A scalar binding is a transparent synonym: its name splits on `.` into an
|
|
463
|
+
* absolute command path aliasing exactly one trail — zero or multiple matches
|
|
464
|
+
* are a `ValidationError` naming the binding and its matches. A list binding
|
|
465
|
+
* is a command group: each expanded member trail gets an alias route at
|
|
466
|
+
* `[...groupName.split('.'), ...memberTrailId.split('.')]`, and a group whose
|
|
467
|
+
* member union is empty is a `ValidationError`. Binding names and member ids
|
|
468
|
+
* are processed in sorted order so the expansion is deterministic. Returns
|
|
469
|
+
* `undefined` when there are no `cli` bindings.
|
|
470
|
+
*
|
|
471
|
+
* @example
|
|
472
|
+
* ```ts
|
|
473
|
+
* import { expandCliSurfaceBindings } from '@ontrails/core';
|
|
474
|
+
*
|
|
475
|
+
* expandCliSurfaceBindings(
|
|
476
|
+
* { 'gear.ls': 'gear.list', gear: ['gear.create', 'gear.list'] },
|
|
477
|
+
* ['gear.create', 'gear.list']
|
|
478
|
+
* );
|
|
479
|
+
* // => {
|
|
480
|
+
* // 'gear.create': [['gear', 'gear', 'create']],
|
|
481
|
+
* // 'gear.list': [['gear', 'gear', 'list'], ['gear', 'ls']],
|
|
482
|
+
* // }
|
|
483
|
+
* ```
|
|
484
|
+
*/
|
|
485
|
+
export const expandCliSurfaceBindings = (
|
|
486
|
+
bindings: SurfaceBindings | undefined,
|
|
487
|
+
trailIds: readonly string[]
|
|
488
|
+
): CliSurfaceBindingAliases | undefined => {
|
|
489
|
+
if (bindings === undefined) {
|
|
490
|
+
return undefined;
|
|
491
|
+
}
|
|
492
|
+
const names = Object.keys(bindings).toSorted();
|
|
493
|
+
if (names.length === 0) {
|
|
494
|
+
return undefined;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
const aliasesByTrail = new Map<string, (readonly string[])[]>();
|
|
498
|
+
const addAlias = (trailId: string, path: readonly string[]): void => {
|
|
499
|
+
const existing = aliasesByTrail.get(trailId);
|
|
500
|
+
if (existing === undefined) {
|
|
501
|
+
aliasesByTrail.set(trailId, [path]);
|
|
502
|
+
return;
|
|
503
|
+
}
|
|
504
|
+
existing.push(path);
|
|
505
|
+
};
|
|
506
|
+
|
|
507
|
+
for (const name of names) {
|
|
508
|
+
const value = bindings[name];
|
|
509
|
+
if (value === undefined) {
|
|
510
|
+
continue;
|
|
511
|
+
}
|
|
512
|
+
const pathSegments = assertCliBindingName(name);
|
|
513
|
+
const shape = classifySurfaceBinding(value);
|
|
514
|
+
if (shape.kind === 'synonym') {
|
|
515
|
+
addAlias(
|
|
516
|
+
expandSynonymBinding(
|
|
517
|
+
trailIds,
|
|
518
|
+
'cli',
|
|
519
|
+
name,
|
|
520
|
+
shape.trail,
|
|
521
|
+
'command group',
|
|
522
|
+
CLI_BINDING_FIX
|
|
523
|
+
),
|
|
524
|
+
pathSegments
|
|
525
|
+
);
|
|
526
|
+
continue;
|
|
527
|
+
}
|
|
528
|
+
for (const member of expandGroupBinding(
|
|
529
|
+
trailIds,
|
|
530
|
+
'cli',
|
|
531
|
+
name,
|
|
532
|
+
shape.members,
|
|
533
|
+
CLI_BINDING_FIX
|
|
534
|
+
)) {
|
|
535
|
+
addAlias(member, [...pathSegments, ...member.split('.')]);
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
return Object.fromEntries(aliasesByTrail);
|
|
540
|
+
};
|
|
541
|
+
|
|
542
|
+
/**
|
|
543
|
+
* The expanded shape of the `surfaces` overlay's `mcp` bindings.
|
|
544
|
+
*
|
|
545
|
+
* `synonyms` maps MCP-safe binding names to the single trail id each one
|
|
546
|
+
* aliases; `groups` maps binding names to the sorted, deduplicated member
|
|
547
|
+
* trail ids of each grouped entry (an MCP trailhead).
|
|
548
|
+
*
|
|
549
|
+
* @example
|
|
550
|
+
* ```ts
|
|
551
|
+
* import type { McpSurfaceBindingExpansion } from '@ontrails/core';
|
|
552
|
+
*
|
|
553
|
+
* const expansion: McpSurfaceBindingExpansion = {
|
|
554
|
+
* groups: { snippets: ['snippet.create', 'snippet.get'] },
|
|
555
|
+
* synonyms: { gear_ls: 'gear.list' },
|
|
556
|
+
* };
|
|
557
|
+
* ```
|
|
558
|
+
*/
|
|
559
|
+
export interface McpSurfaceBindingExpansion {
|
|
560
|
+
/** Grouped entries: binding name to sorted expanded member trail ids. */
|
|
561
|
+
readonly groups: Readonly<Record<string, readonly string[]>>;
|
|
562
|
+
/** Tool synonyms: binding name to the one trail id it aliases. */
|
|
563
|
+
readonly synonyms: Readonly<Record<string, string>>;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
const MCP_SAFE_BINDING_NAME_PATTERN = /^[a-z0-9_]+$/;
|
|
567
|
+
|
|
568
|
+
const assertMcpSynonymBindingName = (name: string): void => {
|
|
569
|
+
if (!MCP_SAFE_BINDING_NAME_PATTERN.test(name)) {
|
|
570
|
+
throw new ValidationError(
|
|
571
|
+
`The "${SURFACES_OVERLAY_NAMESPACE}" overlay mcp binding "${name}" is not an MCP-safe tool name — synonym binding names become tool names verbatim and must use lowercase letters, digits, and underscores only. ${MCP_BINDING_FIX}`
|
|
572
|
+
);
|
|
573
|
+
}
|
|
574
|
+
};
|
|
575
|
+
|
|
576
|
+
/**
|
|
577
|
+
* Derive the deterministic default description for an MCP grouped entry
|
|
578
|
+
* rendered from the `surfaces` overlay.
|
|
579
|
+
*
|
|
580
|
+
* Both the MCP surface (the runtime tool description) and Topography (the
|
|
581
|
+
* lock's trailhead entry description) read this helper, so the authored
|
|
582
|
+
* binding renders one description everywhere.
|
|
583
|
+
*
|
|
584
|
+
* @example
|
|
585
|
+
* ```ts
|
|
586
|
+
* import { deriveMcpTrailheadDescription } from '@ontrails/core';
|
|
587
|
+
*
|
|
588
|
+
* deriveMcpTrailheadDescription(['gear.create', 'gear.list']);
|
|
589
|
+
* // => 'Grouped MCP entry over: gear.create, gear.list.'
|
|
590
|
+
* ```
|
|
591
|
+
*/
|
|
592
|
+
export const deriveMcpTrailheadDescription = (
|
|
593
|
+
memberIds: readonly string[]
|
|
594
|
+
): string => `Grouped MCP entry over: ${memberIds.join(', ')}.`;
|
|
595
|
+
|
|
596
|
+
/**
|
|
597
|
+
* Expand the `surfaces` overlay's `mcp` bindings against the topo's trail
|
|
598
|
+
* ids, validating every binding.
|
|
599
|
+
*
|
|
600
|
+
* A scalar binding is a tool synonym: its name is published verbatim as an
|
|
601
|
+
* additional MCP tool name, so it must be MCP-safe (`[a-z0-9_]+`) and must
|
|
602
|
+
* expand to exactly one trail — zero or multiple matches are a
|
|
603
|
+
* `ValidationError` naming the binding. A list binding is a grouped entry
|
|
604
|
+
* (an MCP trailhead): its members are the sorted union of the expanded
|
|
605
|
+
* selectors, and an empty union is a `ValidationError`. Binding names are
|
|
606
|
+
* processed in sorted order so the expansion is deterministic. Returns
|
|
607
|
+
* `undefined` when there are no `mcp` bindings.
|
|
608
|
+
*
|
|
609
|
+
* @example
|
|
610
|
+
* ```ts
|
|
611
|
+
* import { expandMcpSurfaceBindings } from '@ontrails/core';
|
|
612
|
+
*
|
|
613
|
+
* expandMcpSurfaceBindings(
|
|
614
|
+
* { gear_ls: 'gear.list', gear: ['gear.*'] },
|
|
615
|
+
* ['gear.create', 'gear.list']
|
|
616
|
+
* );
|
|
617
|
+
* // => {
|
|
618
|
+
* // groups: { gear: ['gear.create', 'gear.list'] },
|
|
619
|
+
* // synonyms: { gear_ls: 'gear.list' },
|
|
620
|
+
* // }
|
|
621
|
+
* ```
|
|
622
|
+
*/
|
|
623
|
+
export const expandMcpSurfaceBindings = (
|
|
624
|
+
bindings: SurfaceBindings | undefined,
|
|
625
|
+
trailIds: readonly string[]
|
|
626
|
+
): McpSurfaceBindingExpansion | undefined => {
|
|
627
|
+
if (bindings === undefined) {
|
|
628
|
+
return undefined;
|
|
629
|
+
}
|
|
630
|
+
const names = Object.keys(bindings).toSorted();
|
|
631
|
+
if (names.length === 0) {
|
|
632
|
+
return undefined;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
const groups: Record<string, readonly string[]> = {};
|
|
636
|
+
const synonyms: Record<string, string> = {};
|
|
637
|
+
for (const name of names) {
|
|
638
|
+
const value = bindings[name];
|
|
639
|
+
if (value === undefined) {
|
|
640
|
+
continue;
|
|
641
|
+
}
|
|
642
|
+
const shape = classifySurfaceBinding(value);
|
|
643
|
+
if (shape.kind === 'synonym') {
|
|
644
|
+
assertMcpSynonymBindingName(name);
|
|
645
|
+
synonyms[name] = expandSynonymBinding(
|
|
646
|
+
trailIds,
|
|
647
|
+
'mcp',
|
|
648
|
+
name,
|
|
649
|
+
shape.trail,
|
|
650
|
+
'grouped entry',
|
|
651
|
+
MCP_BINDING_FIX
|
|
652
|
+
);
|
|
653
|
+
continue;
|
|
654
|
+
}
|
|
655
|
+
groups[name] = expandGroupBinding(
|
|
656
|
+
trailIds,
|
|
657
|
+
'mcp',
|
|
658
|
+
name,
|
|
659
|
+
shape.members,
|
|
660
|
+
MCP_BINDING_FIX
|
|
661
|
+
);
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
return { groups, synonyms };
|
|
665
|
+
};
|
|
666
|
+
|
|
667
|
+
/**
|
|
668
|
+
* Read and validate the `surfaces` bindings from a lock's overlays record.
|
|
669
|
+
*
|
|
670
|
+
* The compile-side gate guarantees only app-authored envelopes can own the
|
|
671
|
+
* `surfaces` namespace in a committed lock, so this reader validates shape
|
|
672
|
+
* only — provenance was already enforced before the facts were embedded.
|
|
673
|
+
* Returns `undefined` when the record has no `surfaces` key; throws a
|
|
674
|
+
* `ValidationError` when the embedded facts fail the bindings schema.
|
|
675
|
+
*
|
|
676
|
+
* @example
|
|
677
|
+
* ```ts
|
|
678
|
+
* import { surfaceBindingsFromLockOverlays } from '@ontrails/core';
|
|
679
|
+
*
|
|
680
|
+
* const bindings = surfaceBindingsFromLockOverlays(lock.topoGraph.overlays);
|
|
681
|
+
* ```
|
|
682
|
+
*/
|
|
683
|
+
export const surfaceBindingsFromLockOverlays = (
|
|
684
|
+
overlays: Readonly<Record<string, unknown>> | undefined
|
|
685
|
+
): SurfaceOverlayBindings | undefined => {
|
|
686
|
+
const facts = overlays?.[SURFACES_OVERLAY_NAMESPACE];
|
|
687
|
+
if (facts === undefined) {
|
|
688
|
+
return undefined;
|
|
689
|
+
}
|
|
690
|
+
return parseSurfaceOverlayBindings(
|
|
691
|
+
facts,
|
|
692
|
+
'Regenerate the lock with `trails compile` from the authored surfaceOverlay() bindings.'
|
|
693
|
+
);
|
|
694
|
+
};
|