@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.
Files changed (86) hide show
  1. package/CHANGELOG.md +849 -0
  2. package/README.md +190 -0
  3. package/package.json +36 -0
  4. package/src/activation-provenance.ts +116 -0
  5. package/src/activation-source-compatibility.ts +430 -0
  6. package/src/activation-source-derivation.ts +227 -0
  7. package/src/activation-source.ts +93 -0
  8. package/src/blob-ref.ts +90 -0
  9. package/src/branded.ts +135 -0
  10. package/src/collections.ts +99 -0
  11. package/src/compose-batch.ts +69 -0
  12. package/src/compose-schema.ts +36 -0
  13. package/src/context.ts +66 -0
  14. package/src/derive.ts +485 -0
  15. package/src/detours.ts +8 -0
  16. package/src/diagnostics.ts +21 -0
  17. package/src/draft.ts +350 -0
  18. package/src/entity.ts +346 -0
  19. package/src/error-rendering.ts +87 -0
  20. package/src/errors.ts +483 -0
  21. package/src/execute.ts +1577 -0
  22. package/src/fetch.ts +138 -0
  23. package/src/fire.ts +1172 -0
  24. package/src/glob.ts +81 -0
  25. package/src/guards.ts +37 -0
  26. package/src/index.ts +704 -0
  27. package/src/internal/fork-ctx.ts +69 -0
  28. package/src/layer-field-rendering.ts +193 -0
  29. package/src/layer.ts +81 -0
  30. package/src/observe.ts +361 -0
  31. package/src/path-scope.ts +66 -0
  32. package/src/path-security.ts +98 -0
  33. package/src/patterns/bulk.ts +16 -0
  34. package/src/patterns/change.ts +12 -0
  35. package/src/patterns/date-range.ts +12 -0
  36. package/src/patterns/index.ts +8 -0
  37. package/src/patterns/pagination.ts +22 -0
  38. package/src/patterns/progress.ts +13 -0
  39. package/src/patterns/sorting.ts +14 -0
  40. package/src/patterns/status.ts +11 -0
  41. package/src/patterns/timestamps.ts +12 -0
  42. package/src/permits.ts +12 -0
  43. package/src/queue.ts +163 -0
  44. package/src/redaction/index.ts +3 -0
  45. package/src/redaction/patterns.ts +50 -0
  46. package/src/redaction/redactor.ts +178 -0
  47. package/src/resilience.ts +234 -0
  48. package/src/resource-config.ts +804 -0
  49. package/src/resource.ts +194 -0
  50. package/src/result.ts +212 -0
  51. package/src/run.ts +76 -0
  52. package/src/runtime-builtins.ts +69 -0
  53. package/src/schedule-runtime.ts +689 -0
  54. package/src/schedule.ts +326 -0
  55. package/src/serialization.ts +265 -0
  56. package/src/sha256.ts +136 -0
  57. package/src/signal-diagnostics.ts +633 -0
  58. package/src/signal-ref.ts +111 -0
  59. package/src/signal.ts +104 -0
  60. package/src/store/accessor-protocol.ts +56 -0
  61. package/src/store/index.ts +4 -0
  62. package/src/structured-examples.ts +248 -0
  63. package/src/surface-derivation.ts +91 -0
  64. package/src/surface-filter.ts +101 -0
  65. package/src/surface-overlay.ts +694 -0
  66. package/src/surface-versioning.ts +42 -0
  67. package/src/topo.ts +835 -0
  68. package/src/tracing.ts +346 -0
  69. package/src/trail-id-glob.ts +15 -0
  70. package/src/trail.ts +1351 -0
  71. package/src/trails/derive-trail.ts +835 -0
  72. package/src/trails/index.ts +9 -0
  73. package/src/trails/ingest.ts +152 -0
  74. package/src/trails-db.ts +212 -0
  75. package/src/transport-error-map.ts +163 -0
  76. package/src/type-utils.ts +87 -0
  77. package/src/types.ts +300 -0
  78. package/src/validate-established-topo.ts +73 -0
  79. package/src/validate-topo.ts +725 -0
  80. package/src/validation.ts +330 -0
  81. package/src/version-marker.ts +716 -0
  82. package/src/version-resolution.ts +308 -0
  83. package/src/version-runtime.ts +120 -0
  84. package/src/webhook.ts +461 -0
  85. package/src/workspace.ts +244 -0
  86. package/src/zod-wrappers.ts +72 -0
package/src/derive.ts ADDED
@@ -0,0 +1,485 @@
1
+ /**
2
+ * Schema-driven field derivation for @ontrails/core
3
+ *
4
+ * Introspects Zod v4 schemas to produce a runtime-agnostic Field[] descriptor
5
+ * that UI consumers (CLI prompts, web forms, etc.) can consume.
6
+ */
7
+
8
+ import type { z } from 'zod';
9
+
10
+ import { ValidationError } from './errors.js';
11
+
12
+ // ---------------------------------------------------------------------------
13
+ // Public types
14
+ // ---------------------------------------------------------------------------
15
+
16
+ /** A runtime-agnostic field descriptor derived from a Zod schema. */
17
+ export interface Field {
18
+ readonly name: string;
19
+ readonly type:
20
+ | 'string'
21
+ | 'number'
22
+ | 'boolean'
23
+ | 'enum'
24
+ | 'multiselect'
25
+ | 'string[]'
26
+ | 'number[]';
27
+ readonly label: string;
28
+ readonly required: boolean;
29
+ readonly default?: unknown | undefined;
30
+ readonly options?:
31
+ | readonly {
32
+ value: string;
33
+ label?: string | undefined;
34
+ hint?: string | undefined;
35
+ }[]
36
+ | undefined;
37
+ }
38
+
39
+ /** Per-field overrides supplied by trail authors. */
40
+ export interface FieldOverride {
41
+ readonly label?: string | undefined;
42
+ readonly message?: string | undefined;
43
+ readonly hint?: string | undefined;
44
+ readonly options?:
45
+ | readonly {
46
+ value: string;
47
+ label: string;
48
+ hint?: string | undefined;
49
+ }[]
50
+ | undefined;
51
+ }
52
+
53
+ // ---------------------------------------------------------------------------
54
+ // CLI command route rendering
55
+ // ---------------------------------------------------------------------------
56
+
57
+ /** Authored CLI command path shape. Strings are split on whitespace. */
58
+ export type CliCommandPathInput = string | readonly string[];
59
+
60
+ /**
61
+ * Authored CLI command alias shape.
62
+ *
63
+ * String aliases are sibling leaf aliases. Array aliases are absolute command
64
+ * paths.
65
+ */
66
+ export type CliCommandAliasInput = string | readonly string[];
67
+
68
+ /** Source that produced a resolved CLI command route. */
69
+ export type CliCommandRouteSource = 'derived' | 'trail' | 'surface';
70
+
71
+ /** Whether a resolved CLI command route is canonical or an alias. */
72
+ export type CliCommandRouteKind = 'alias' | 'canonical';
73
+
74
+ /** Trail-authored CLI rendering metadata. */
75
+ export interface TrailCliRendering {
76
+ readonly aliases?: readonly CliCommandAliasInput[] | undefined;
77
+ readonly path?: CliCommandPathInput | undefined;
78
+ }
79
+
80
+ /** A resolved command path accepted by a CLI surface for one trail. */
81
+ export interface CliCommandRoute {
82
+ readonly kind: CliCommandRouteKind;
83
+ readonly path: readonly string[];
84
+ readonly source: CliCommandRouteSource;
85
+ readonly target: string;
86
+ }
87
+
88
+ /** Resolved CLI rendering for one trail. */
89
+ export interface TrailCliCommandRendering {
90
+ readonly path: readonly string[];
91
+ readonly routes: readonly CliCommandRoute[];
92
+ }
93
+
94
+ interface TrailCliRenderingInput {
95
+ readonly cli?: CliCommandPathInput | TrailCliRendering | undefined;
96
+ readonly id: string;
97
+ }
98
+
99
+ export interface DeriveTrailCliCommandOptions {
100
+ readonly aliases?: readonly CliCommandAliasInput[] | undefined;
101
+ readonly aliasSource?: Extract<CliCommandRouteSource, 'surface' | 'trail'>;
102
+ }
103
+
104
+ // ---------------------------------------------------------------------------
105
+ // Zod v4 internals accessor
106
+ // ---------------------------------------------------------------------------
107
+
108
+ interface ZodInternals {
109
+ readonly _zod: {
110
+ readonly def: Readonly<Record<string, unknown>>;
111
+ readonly traits: ReadonlySet<string>;
112
+ };
113
+ readonly description?: string | undefined;
114
+ }
115
+
116
+ // ---------------------------------------------------------------------------
117
+ // Helpers
118
+ // ---------------------------------------------------------------------------
119
+
120
+ /** Convert camelCase / PascalCase to "Title Case" label. */
121
+ const humanize = (str: string): string =>
122
+ str
123
+ .replaceAll(/([a-z])([A-Z])/g, '$1 $2')
124
+ .replaceAll(/([A-Z]+)([A-Z][a-z])/g, '$1 $2')
125
+ .replace(/^./, (ch) => ch.toUpperCase());
126
+
127
+ interface UnwrapResult {
128
+ defaultValue: unknown;
129
+ description: string | undefined;
130
+ inner: ZodInternals;
131
+ required: boolean;
132
+ }
133
+
134
+ /** Get the inner type from an optional or default wrapper. */
135
+ const getInnerType = (current: ZodInternals): ZodInternals =>
136
+ current._zod.def['innerType'] as ZodInternals;
137
+
138
+ /** Propagate description from inner to state if present. */
139
+ const propagateDescription = (
140
+ inner: ZodInternals,
141
+ state: { description: string | undefined }
142
+ ): void => {
143
+ if (inner.description) {
144
+ state.description = inner.description;
145
+ }
146
+ };
147
+
148
+ /** Step one level of transparent wrapper unwrapping. Returns null if not a wrapper type. */
149
+ const unwrapStep = (
150
+ current: ZodInternals,
151
+ state: {
152
+ defaultValue: unknown;
153
+ description: string | undefined;
154
+ required: boolean;
155
+ }
156
+ ): ZodInternals | null => {
157
+ const defType = current._zod.def['type'] as string;
158
+ if (
159
+ defType !== 'optional' &&
160
+ defType !== 'default' &&
161
+ defType !== 'readonly'
162
+ ) {
163
+ return null;
164
+ }
165
+ if (defType !== 'readonly') {
166
+ state.required = false;
167
+ }
168
+ if (defType === 'default') {
169
+ state.defaultValue = current._zod.def['defaultValue'];
170
+ }
171
+ const inner = getInnerType(current);
172
+ propagateDescription(inner, state);
173
+ return inner;
174
+ };
175
+
176
+ /** Unwrap optional / default wrappers, collecting metadata. */
177
+ const unwrap = (s: ZodInternals): UnwrapResult => {
178
+ const state = {
179
+ defaultValue: undefined as unknown,
180
+ description: s.description,
181
+ required: true,
182
+ };
183
+ let current = s;
184
+
185
+ // eslint-disable-next-line no-constant-condition
186
+ while (true) {
187
+ const next = unwrapStep(current, state);
188
+ if (next === null) {
189
+ break;
190
+ }
191
+ current = next;
192
+ }
193
+
194
+ return { ...state, inner: current };
195
+ };
196
+
197
+ interface DerivedFieldType {
198
+ options: string[] | undefined;
199
+ type: Field['type'];
200
+ }
201
+
202
+ const fieldTypeByDef: Record<
203
+ string,
204
+ (s: ZodInternals) => DerivedFieldType | null
205
+ > = {
206
+ array: (s) => {
207
+ const element = s._zod.def['element'] as unknown as ZodInternals;
208
+ const { inner } = unwrap(element);
209
+ const elementType = inner._zod.def['type'] as string;
210
+ if (elementType === 'enum') {
211
+ const entries = inner._zod.def['entries'] as Record<string, string>;
212
+ return { options: Object.values(entries), type: 'multiselect' };
213
+ }
214
+ if (elementType !== 'number' && elementType !== 'string') {
215
+ return null;
216
+ }
217
+ return {
218
+ options: undefined,
219
+ type: elementType === 'number' ? 'number[]' : 'string[]',
220
+ };
221
+ },
222
+ boolean: () => ({ options: undefined, type: 'boolean' }),
223
+ enum: (s) => {
224
+ const entries = s._zod.def['entries'] as Record<string, string>;
225
+ return { options: Object.values(entries), type: 'enum' };
226
+ },
227
+ number: () => ({ options: undefined, type: 'number' }),
228
+ string: () => ({ options: undefined, type: 'string' }),
229
+ };
230
+
231
+ /** Derive field type and raw options from the unwrapped Zod def. */
232
+ const deriveFieldType = (s: ZodInternals): DerivedFieldType | null => {
233
+ const defType = s._zod.def['type'] as string;
234
+ const derive = fieldTypeByDef[defType];
235
+ return derive ? derive(s) : null;
236
+ };
237
+
238
+ /** Build options array, merging with overrides when present. */
239
+ const buildOptions = (
240
+ rawOptions: string[] | undefined,
241
+ overrideOptions: FieldOverride['options'] | undefined
242
+ ): Field['options'] | undefined => {
243
+ if (!rawOptions) {
244
+ return undefined;
245
+ }
246
+
247
+ if (!overrideOptions) {
248
+ return rawOptions.map((v) => ({ value: v }));
249
+ }
250
+
251
+ const overrideMap = new Map(overrideOptions.map((o) => [o.value, o]));
252
+ return rawOptions.map((v) => {
253
+ const ov = overrideMap.get(v);
254
+ return ov ? { hint: ov.hint, label: ov.label, value: v } : { value: v };
255
+ });
256
+ };
257
+
258
+ /**
259
+ * Derive the canonical ordered CLI path from a trail ID.
260
+ *
261
+ * @throws {ValidationError} if the trail ID contains empty segments (e.g. consecutive dots).
262
+ */
263
+ export const deriveCliPath = (trailId: string): string[] => {
264
+ const segments = trailId.split('.');
265
+ const emptyIndex = segments.findIndex((s) => s.length === 0);
266
+ if (emptyIndex !== -1) {
267
+ throw new ValidationError(
268
+ `Trail ID "${trailId}" contains an empty segment at position ${emptyIndex}`
269
+ );
270
+ }
271
+ return segments;
272
+ };
273
+
274
+ const hasWhitespace = (value: string): boolean => /\s/.test(value);
275
+
276
+ const validateCliSegment = (segment: string, context: string): string => {
277
+ const normalized = segment.trim();
278
+ if (normalized.length === 0) {
279
+ throw new ValidationError(`${context} cannot contain empty segments`);
280
+ }
281
+ if (hasWhitespace(normalized)) {
282
+ throw new ValidationError(
283
+ `${context} segment "${segment}" cannot contain whitespace`
284
+ );
285
+ }
286
+ return normalized;
287
+ };
288
+
289
+ const splitCliPathString = (value: string, context: string): string[] => {
290
+ const segments = value
291
+ .trim()
292
+ .split(/\s+/)
293
+ .filter((segment) => segment.length > 0);
294
+ if (segments.length === 0) {
295
+ throw new ValidationError(`${context} cannot be empty`);
296
+ }
297
+ return segments.map((segment) => validateCliSegment(segment, context));
298
+ };
299
+
300
+ /** Normalize an authored CLI command path. */
301
+ export const normalizeCliCommandPath = (
302
+ value: CliCommandPathInput,
303
+ context = 'CLI command path'
304
+ ): readonly string[] =>
305
+ typeof value === 'string'
306
+ ? splitCliPathString(value, context)
307
+ : value.map((segment) => validateCliSegment(segment, context));
308
+
309
+ const isTrailCliRendering = (
310
+ value: CliCommandPathInput | TrailCliRendering
311
+ ): value is TrailCliRendering =>
312
+ typeof value !== 'string' &&
313
+ !Array.isArray(value) &&
314
+ value !== null &&
315
+ typeof value === 'object';
316
+
317
+ const trailCliRenderingFor = (
318
+ trail: TrailCliRenderingInput
319
+ ): TrailCliRendering | undefined => {
320
+ if (trail.cli === undefined) {
321
+ return undefined;
322
+ }
323
+ return isTrailCliRendering(trail.cli) ? trail.cli : { path: trail.cli };
324
+ };
325
+
326
+ const deriveCanonicalCliRoute = (
327
+ trail: TrailCliRenderingInput
328
+ ): CliCommandRoute => {
329
+ const rendering = trailCliRenderingFor(trail);
330
+ const path =
331
+ rendering?.path === undefined
332
+ ? deriveCliPath(trail.id)
333
+ : normalizeCliCommandPath(
334
+ rendering.path,
335
+ `CLI command path for trail "${trail.id}"`
336
+ );
337
+ return {
338
+ kind: 'canonical',
339
+ path,
340
+ source: rendering?.path === undefined ? 'derived' : 'trail',
341
+ target: trail.id,
342
+ };
343
+ };
344
+
345
+ const normalizeCliAlias = ({
346
+ alias,
347
+ canonicalPath,
348
+ source,
349
+ target,
350
+ }: {
351
+ readonly alias: CliCommandAliasInput;
352
+ readonly canonicalPath: readonly string[];
353
+ readonly source: Extract<CliCommandRouteSource, 'surface' | 'trail'>;
354
+ readonly target: string;
355
+ }): CliCommandRoute => {
356
+ const context = `CLI command alias for trail "${target}"`;
357
+ if (typeof alias === 'string') {
358
+ const segment = alias.trim();
359
+ if (segment.length === 0) {
360
+ throw new ValidationError(`${context} cannot be empty`);
361
+ }
362
+ if (hasWhitespace(segment)) {
363
+ throw new ValidationError(
364
+ `${context} must be a single command segment; use a string array for absolute paths`
365
+ );
366
+ }
367
+ return {
368
+ kind: 'alias',
369
+ path: [
370
+ ...canonicalPath.slice(0, -1),
371
+ validateCliSegment(segment, context),
372
+ ],
373
+ source,
374
+ target,
375
+ };
376
+ }
377
+ return {
378
+ kind: 'alias',
379
+ path: normalizeCliCommandPath(alias, context),
380
+ source,
381
+ target,
382
+ };
383
+ };
384
+
385
+ /**
386
+ * Convert app name + trail ID to an MCP-safe tool name.
387
+ *
388
+ * MCP tool names must be `[a-z0-9_]+`: the app name prefixes the trail id,
389
+ * dots and hyphens collapse to underscores, and everything lowercases. This
390
+ * is the one owner for the rendering — the MCP surface renders tools with
391
+ * it and Warden checks binding-name collisions against it, so the two
392
+ * readers cannot drift.
393
+ *
394
+ * @example
395
+ * ```ts
396
+ * deriveMcpToolName('myapp', 'entity.show'); // "myapp_entity_show"
397
+ * deriveMcpToolName('dispatch', 'patch.search'); // "dispatch_patch_search"
398
+ * ```
399
+ */
400
+ export const deriveMcpToolName = (appName: string, trailId: string): string => {
401
+ const prefix = appName.toLowerCase().replaceAll(/[.-]/g, '_');
402
+ const suffix = trailId.toLowerCase().replaceAll(/[.-]/g, '_');
403
+ return `${prefix}_${suffix}`;
404
+ };
405
+
406
+ /** Derive resolved CLI command routes for one trail. */
407
+ export const deriveTrailCliCommandRendering = (
408
+ trail: TrailCliRenderingInput,
409
+ options?: DeriveTrailCliCommandOptions
410
+ ): TrailCliCommandRendering => {
411
+ const canonical = deriveCanonicalCliRoute(trail);
412
+ const rendering = trailCliRenderingFor(trail);
413
+ const trailAliases =
414
+ rendering?.aliases?.map((alias) =>
415
+ normalizeCliAlias({
416
+ alias,
417
+ canonicalPath: canonical.path,
418
+ source: 'trail',
419
+ target: trail.id,
420
+ })
421
+ ) ?? [];
422
+ const surfaceAliases =
423
+ options?.aliases?.map((alias) =>
424
+ normalizeCliAlias({
425
+ alias,
426
+ canonicalPath: canonical.path,
427
+ source: options.aliasSource ?? 'surface',
428
+ target: trail.id,
429
+ })
430
+ ) ?? [];
431
+
432
+ return {
433
+ path: canonical.path,
434
+ routes: [canonical, ...trailAliases, ...surfaceAliases],
435
+ };
436
+ };
437
+
438
+ // ---------------------------------------------------------------------------
439
+ // Public API
440
+ // ---------------------------------------------------------------------------
441
+
442
+ /** Derive a single field from a shape entry. */
443
+ const deriveField = (
444
+ key: string,
445
+ value: ZodInternals,
446
+ overrides?: Record<string, FieldOverride>
447
+ ): Field | null => {
448
+ const { inner, required, defaultValue, description } = unwrap(value);
449
+ const derived = deriveFieldType(inner);
450
+ if (!derived) {
451
+ return null;
452
+ }
453
+ const { type, options: rawOptions } = derived;
454
+ const override = overrides?.[key];
455
+ const label = override?.label ?? description ?? humanize(key);
456
+ const options = buildOptions(rawOptions, override?.options);
457
+ return { default: defaultValue, label, name: key, options, required, type };
458
+ };
459
+
460
+ /**
461
+ * Derive a runtime-agnostic Field[] from a Zod object schema.
462
+ *
463
+ * Uses Zod v4's `_zod.def` for introspection. Returns fields sorted by name.
464
+ */
465
+ export const deriveFields = (
466
+ schema: z.ZodType,
467
+ overrides?: Record<string, FieldOverride>
468
+ ): Field[] => {
469
+ const s = schema as unknown as ZodInternals;
470
+ if ((s._zod.def['type'] as string) !== 'object') {
471
+ return [];
472
+ }
473
+
474
+ const shape = s._zod.def['shape'] as Record<string, ZodInternals> | undefined;
475
+ if (!shape) {
476
+ return [];
477
+ }
478
+
479
+ const fields = Object.entries(shape).map(([key, value]) =>
480
+ deriveField(key, value, overrides)
481
+ );
482
+ return fields
483
+ .filter((field): field is Field => field !== null)
484
+ .toSorted((a, b) => a.name.localeCompare(b.name));
485
+ };
package/src/detours.ts ADDED
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Hard upper bound for detour recovery attempts.
3
+ *
4
+ * Execution and derived surface/topo facts both clamp declared detour
5
+ * attempts to this value so runtime behavior and inspectable contracts stay in
6
+ * lockstep.
7
+ */
8
+ export const DETOUR_MAX_ATTEMPTS_CAP = 5;
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Shared diagnostic vocabulary for governance-style findings.
3
+ *
4
+ * Runtime side-channel records and field-state reports can still define their
5
+ * own shapes. This base exists for tools that report rule or check failures.
6
+ */
7
+
8
+ export type DiagnosticSeverity = 'error' | 'warn';
9
+
10
+ export interface DiagnosticBase<TCode extends string = string> {
11
+ readonly code?: TCode | undefined;
12
+ readonly message: string;
13
+ readonly severity: DiagnosticSeverity;
14
+ }
15
+
16
+ export interface RuleDiagnosticBase<
17
+ TCode extends string = string,
18
+ TRule extends string = string,
19
+ > extends DiagnosticBase<TCode> {
20
+ readonly rule: TRule;
21
+ }