@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
@@ -0,0 +1,689 @@
1
+ import type { ActivationEntry } from './activation-source.js';
2
+ import { activationSourceKey } from './activation-source-derivation.js';
3
+ import {
4
+ buildActivationProvenanceTraceAttrs,
5
+ withActivationProvenance,
6
+ } from './activation-provenance.js';
7
+ import type { ActivationProvenance } from './activation-provenance.js';
8
+ import { getActivationWherePredicate } from './activation-source.js';
9
+ import { createTrailContext } from './context.js';
10
+ import type { ExecuteTrailOptions } from './execute.js';
11
+ import { ConflictError, InternalError } from './errors.js';
12
+ import {
13
+ getTraceSink,
14
+ TRACE_CONTEXT_KEY,
15
+ traceContextFromRecord,
16
+ writeActivationTraceRecord,
17
+ } from './tracing.js';
18
+ import type { TraceContext } from './tracing.js';
19
+ import { Result } from './result.js';
20
+ import { drainResources } from './resource-config.js';
21
+ import type { ResourceDrainReport } from './resource-config.js';
22
+ import { run } from './run.js';
23
+ import type { Topo } from './topo.js';
24
+ import type { AnyTrail } from './trail.js';
25
+ import type { Logger, TrailContext, TrailContextInit } from './types.js';
26
+ import { validateTopo } from './validate-topo.js';
27
+
28
+ type ScheduleInputDefault = Record<string, never>;
29
+
30
+ export type ScheduleRuntimeState = 'idle' | 'running' | 'stopped' | 'stopping';
31
+
32
+ export type ScheduleCronHandler = () => Promise<void> | void;
33
+
34
+ export interface ScheduleCronJob {
35
+ readonly cron?: string | undefined;
36
+ ref?: (() => unknown) | undefined;
37
+ stop(): unknown;
38
+ unref?: (() => unknown) | undefined;
39
+ }
40
+
41
+ export type ScheduleCronFactory = (
42
+ cron: string,
43
+ handler: ScheduleCronHandler
44
+ ) => ScheduleCronJob;
45
+
46
+ export type ScheduleRuntimeLogger = Pick<Logger, 'debug' | 'error' | 'warn'>;
47
+
48
+ export type ScheduleRuntimeSkipReason =
49
+ | 'stopped'
50
+ | 'where_error'
51
+ | 'where_false';
52
+
53
+ export type ScheduleRuntimeRunStatus = 'err' | 'ok' | 'skipped';
54
+
55
+ export interface ScheduleRuntimeRunRecord {
56
+ readonly activation?: ActivationProvenance | undefined;
57
+ readonly error?: Error | undefined;
58
+ readonly result?: Result<unknown, Error> | undefined;
59
+ readonly skipReason?: ScheduleRuntimeSkipReason | undefined;
60
+ readonly sourceId: string;
61
+ readonly status: ScheduleRuntimeRunStatus;
62
+ readonly trailId: string;
63
+ }
64
+
65
+ export interface ScheduleRuntimeOptions extends Pick<
66
+ ExecuteTrailOptions,
67
+ | 'abortSignal'
68
+ | 'configValues'
69
+ | 'createContext'
70
+ | 'ctx'
71
+ | 'layers'
72
+ | 'resources'
73
+ > {
74
+ readonly cron?: ScheduleCronFactory | undefined;
75
+ readonly logger?: ScheduleRuntimeLogger | undefined;
76
+ readonly onRun?:
77
+ | ((record: ScheduleRuntimeRunRecord) => Promise<void> | void)
78
+ | undefined;
79
+ readonly unref?: boolean | undefined;
80
+ }
81
+
82
+ export interface ScheduleRuntimeRegistrationReport {
83
+ readonly cron: string;
84
+ readonly sourceId: string;
85
+ readonly timezone?: string | undefined;
86
+ readonly trailId: string;
87
+ }
88
+
89
+ export type ScheduleRuntimeWarningCode = 'schedule_timezone_metadata_only';
90
+
91
+ export interface ScheduleRuntimeWarning {
92
+ readonly code: ScheduleRuntimeWarningCode;
93
+ readonly message: string;
94
+ readonly sourceId: string;
95
+ readonly timezone: string;
96
+ readonly trailId: string;
97
+ }
98
+
99
+ export interface ScheduleRuntimeStartReport {
100
+ readonly registered: readonly ScheduleRuntimeRegistrationReport[];
101
+ readonly warnings: readonly ScheduleRuntimeWarning[];
102
+ }
103
+
104
+ export interface ScheduleRuntimeStopReport {
105
+ readonly resources: ResourceDrainReport;
106
+ readonly settledRuns: number;
107
+ readonly stopped: readonly ScheduleRuntimeRegistrationReport[];
108
+ }
109
+
110
+ export interface ScheduleRuntime {
111
+ readonly state: () => ScheduleRuntimeState;
112
+ readonly start: () => Promise<Result<ScheduleRuntimeStartReport, Error>>;
113
+ readonly stop: () => Promise<Result<ScheduleRuntimeStopReport, Error>>;
114
+ }
115
+
116
+ interface BunCronGlobal {
117
+ readonly Bun?: {
118
+ readonly cron?: ScheduleCronFactory | undefined;
119
+ };
120
+ }
121
+
122
+ interface ScheduleActivationRegistration {
123
+ readonly activation: ActivationEntry;
124
+ readonly cron: string;
125
+ readonly input: unknown;
126
+ readonly sourceId: string;
127
+ readonly timezone?: string | undefined;
128
+ readonly trail: AnyTrail;
129
+ }
130
+
131
+ interface RunningScheduleRegistration extends ScheduleActivationRegistration {
132
+ readonly job: ScheduleCronJob;
133
+ }
134
+
135
+ const EMPTY_INPUT = Object.freeze({}) as ScheduleInputDefault;
136
+
137
+ const defaultCronFactory: ScheduleCronFactory = (cron, handler) => {
138
+ const bunCron = (globalThis as unknown as BunCronGlobal).Bun?.cron;
139
+ if (typeof bunCron !== 'function') {
140
+ throw new InternalError(
141
+ 'Bun.cron is not available in this runtime; provide a cron factory to createScheduleRuntime()'
142
+ );
143
+ }
144
+ return bunCron(cron, handler);
145
+ };
146
+
147
+ const errorFromUnknown = (error: unknown): Error =>
148
+ error instanceof Error ? error : new Error(String(error));
149
+
150
+ const errorRecord = (error: Error): Record<string, unknown> => ({
151
+ message: error.message,
152
+ name: error.name,
153
+ ...(error.cause instanceof Error
154
+ ? { cause: { message: error.cause.message, name: error.cause.name } }
155
+ : {}),
156
+ });
157
+
158
+ const registrationReport = (
159
+ registration: ScheduleActivationRegistration
160
+ ): ScheduleRuntimeRegistrationReport => ({
161
+ cron: registration.cron,
162
+ sourceId: registration.sourceId,
163
+ ...(registration.timezone === undefined
164
+ ? {}
165
+ : { timezone: registration.timezone }),
166
+ trailId: registration.trail.id,
167
+ });
168
+
169
+ const timezoneMetadataWarning = (
170
+ registration: ScheduleActivationRegistration
171
+ ): ScheduleRuntimeWarning | undefined =>
172
+ registration.timezone === undefined || registration.timezone === 'UTC'
173
+ ? undefined
174
+ : {
175
+ code: 'schedule_timezone_metadata_only',
176
+ message:
177
+ 'The built-in Bun cron runtime records schedule timezone metadata but does not apply timezone-aware scheduling.',
178
+ sourceId: registration.sourceId,
179
+ timezone: registration.timezone,
180
+ trailId: registration.trail.id,
181
+ };
182
+
183
+ const collectStartWarnings = (
184
+ registrations: readonly ScheduleActivationRegistration[]
185
+ ): ScheduleRuntimeWarning[] =>
186
+ registrations.flatMap((registration) => {
187
+ const warning = timezoneMetadataWarning(registration);
188
+ return warning === undefined ? [] : [warning];
189
+ });
190
+
191
+ const logStartWarnings = (
192
+ warnings: readonly ScheduleRuntimeWarning[],
193
+ logger: ScheduleRuntimeLogger | undefined
194
+ ): void => {
195
+ for (const warning of warnings) {
196
+ logger?.warn(warning.message, {
197
+ code: warning.code,
198
+ sourceId: warning.sourceId,
199
+ timezone: warning.timezone,
200
+ trailId: warning.trailId,
201
+ });
202
+ }
203
+ };
204
+
205
+ const sourceInput = (activation: ActivationEntry): unknown =>
206
+ Object.hasOwn(activation.source, 'input')
207
+ ? activation.source.input
208
+ : EMPTY_INPUT;
209
+
210
+ const collectScheduleActivations = (
211
+ graph: Topo
212
+ ): ScheduleActivationRegistration[] => {
213
+ const registrations = new Map<string, ScheduleActivationRegistration>();
214
+ for (const trail of graph.list()) {
215
+ for (const activation of trail.activationSources) {
216
+ if (activation.source.kind !== 'schedule') {
217
+ continue;
218
+ }
219
+ const key = `${trail.id}\0${activationSourceKey(activation.source)}`;
220
+ const previous = registrations.get(key);
221
+ if (
222
+ previous !== undefined &&
223
+ (previous.activation.where !== undefined ||
224
+ activation.where === undefined)
225
+ ) {
226
+ continue;
227
+ }
228
+ registrations.set(key, {
229
+ activation,
230
+ cron:
231
+ typeof activation.source.cron === 'string'
232
+ ? activation.source.cron
233
+ : '',
234
+ input: sourceInput(activation),
235
+ sourceId: activation.source.id,
236
+ ...(activation.source.timezone === undefined
237
+ ? {}
238
+ : { timezone: activation.source.timezone }),
239
+ trail,
240
+ });
241
+ }
242
+ }
243
+
244
+ return [...registrations.values()];
245
+ };
246
+
247
+ const scheduleActivationProvenance = (
248
+ registration: ScheduleActivationRegistration
249
+ ): ActivationProvenance => {
250
+ const fireId = Bun.randomUUIDv7();
251
+ return {
252
+ fireId,
253
+ rootFireId: fireId,
254
+ source: {
255
+ cron: registration.cron,
256
+ id: registration.sourceId,
257
+ kind: 'schedule',
258
+ ...(registration.activation.source.meta === undefined
259
+ ? {}
260
+ : { meta: registration.activation.source.meta }),
261
+ ...(registration.timezone === undefined
262
+ ? {}
263
+ : { timezone: registration.timezone }),
264
+ },
265
+ };
266
+ };
267
+
268
+ const scheduleActivationTraceAttrs = (
269
+ registration: ScheduleActivationRegistration,
270
+ activation: ActivationProvenance
271
+ ): Readonly<Record<string, unknown>> => ({
272
+ ...buildActivationProvenanceTraceAttrs(activation),
273
+ 'trails.activation.target_trail.id': registration.trail.id,
274
+ });
275
+
276
+ const recordScheduleActivationTrace = async (
277
+ registration: ScheduleActivationRegistration,
278
+ activation: ActivationProvenance,
279
+ graph: Topo
280
+ ): Promise<TraceContext | undefined> => {
281
+ const record = await writeActivationTraceRecord(
282
+ 'activation.scheduled',
283
+ scheduleActivationTraceAttrs(registration, activation),
284
+ 'ok',
285
+ undefined,
286
+ undefined,
287
+ graph.observe?.trace ?? getTraceSink()
288
+ );
289
+ return record === undefined ? undefined : traceContextFromRecord(record);
290
+ };
291
+
292
+ const emitRunRecord = async (
293
+ options: ScheduleRuntimeOptions,
294
+ record: ScheduleRuntimeRunRecord
295
+ ): Promise<void> => {
296
+ try {
297
+ await options.onRun?.(record);
298
+ } catch (error) {
299
+ options.logger?.warn('Schedule runtime onRun callback failed', {
300
+ error: errorFromUnknown(error).message,
301
+ sourceId: record.sourceId,
302
+ trailId: record.trailId,
303
+ });
304
+ }
305
+ };
306
+
307
+ const runContextOption = (
308
+ options: ScheduleRuntimeOptions,
309
+ activation: ActivationProvenance | undefined,
310
+ traceContext?: TraceContext | undefined
311
+ ): Pick<ExecuteTrailOptions, 'ctx'> | Record<string, never> => {
312
+ if (activation === undefined && traceContext === undefined) {
313
+ return options.ctx === undefined ? {} : { ctx: options.ctx };
314
+ }
315
+ const withActivation =
316
+ activation === undefined
317
+ ? (options.ctx ?? {})
318
+ : withActivationProvenance(options.ctx ?? {}, activation);
319
+ const ctx =
320
+ traceContext === undefined
321
+ ? withActivation
322
+ : {
323
+ ...withActivation,
324
+ extensions: {
325
+ ...withActivation.extensions,
326
+ [TRACE_CONTEXT_KEY]: traceContext,
327
+ },
328
+ };
329
+ return { ctx };
330
+ };
331
+
332
+ const runOptions = (
333
+ options: ScheduleRuntimeOptions,
334
+ activation?: ActivationProvenance | undefined,
335
+ traceContext?: TraceContext | undefined
336
+ ): Pick<
337
+ ExecuteTrailOptions,
338
+ | 'abortSignal'
339
+ | 'configValues'
340
+ | 'createContext'
341
+ | 'ctx'
342
+ | 'layers'
343
+ | 'resources'
344
+ > => ({
345
+ ...(options.abortSignal === undefined
346
+ ? {}
347
+ : { abortSignal: options.abortSignal }),
348
+ ...(options.configValues === undefined
349
+ ? {}
350
+ : { configValues: options.configValues }),
351
+ ...(options.createContext === undefined
352
+ ? {}
353
+ : { createContext: options.createContext }),
354
+ ...runContextOption(options, activation, traceContext),
355
+ ...(options.layers === undefined ? {} : { layers: options.layers }),
356
+ ...(options.resources === undefined ? {} : { resources: options.resources }),
357
+ });
358
+
359
+ const shouldRunActivation = async (
360
+ registration: ScheduleActivationRegistration,
361
+ options: ScheduleRuntimeOptions,
362
+ activation: ActivationProvenance
363
+ ): Promise<boolean> => {
364
+ const predicate = getActivationWherePredicate(registration.activation.where);
365
+ if (predicate === undefined) {
366
+ return true;
367
+ }
368
+
369
+ try {
370
+ const matched = await predicate(registration.input);
371
+ if (!matched) {
372
+ await emitRunRecord(options, {
373
+ activation,
374
+ skipReason: 'where_false',
375
+ sourceId: registration.sourceId,
376
+ status: 'skipped',
377
+ trailId: registration.trail.id,
378
+ });
379
+ }
380
+ return matched;
381
+ } catch (error) {
382
+ const cause = errorFromUnknown(error);
383
+ options.logger?.warn('Schedule activation predicate failed', {
384
+ error: cause.message,
385
+ sourceId: registration.sourceId,
386
+ trailId: registration.trail.id,
387
+ });
388
+ await emitRunRecord(options, {
389
+ activation,
390
+ error: cause,
391
+ skipReason: 'where_error',
392
+ sourceId: registration.sourceId,
393
+ status: 'skipped',
394
+ trailId: registration.trail.id,
395
+ });
396
+ return false;
397
+ }
398
+ };
399
+
400
+ const executeScheduleActivation = async (
401
+ graph: Topo,
402
+ registration: ScheduleActivationRegistration,
403
+ options: ScheduleRuntimeOptions,
404
+ activation: ActivationProvenance
405
+ ): Promise<void> => {
406
+ const shouldRun = await shouldRunActivation(
407
+ registration,
408
+ options,
409
+ activation
410
+ );
411
+ if (!shouldRun) {
412
+ return;
413
+ }
414
+
415
+ const traceContext = await recordScheduleActivationTrace(
416
+ registration,
417
+ activation,
418
+ graph
419
+ );
420
+
421
+ try {
422
+ const result = await run(
423
+ graph,
424
+ registration.trail.id,
425
+ registration.input,
426
+ runOptions(options, activation, traceContext)
427
+ );
428
+ if (result.isErr()) {
429
+ options.logger?.warn('Scheduled trail failed', {
430
+ error: result.error.message,
431
+ sourceId: registration.sourceId,
432
+ trailId: registration.trail.id,
433
+ });
434
+ await emitRunRecord(options, {
435
+ activation,
436
+ result,
437
+ sourceId: registration.sourceId,
438
+ status: 'err',
439
+ trailId: registration.trail.id,
440
+ });
441
+ return;
442
+ }
443
+ await emitRunRecord(options, {
444
+ activation,
445
+ result,
446
+ sourceId: registration.sourceId,
447
+ status: 'ok',
448
+ trailId: registration.trail.id,
449
+ });
450
+ } catch (error) {
451
+ const cause = errorFromUnknown(error);
452
+ options.logger?.error('Scheduled trail rejected unexpectedly', {
453
+ error: cause.message,
454
+ sourceId: registration.sourceId,
455
+ trailId: registration.trail.id,
456
+ });
457
+ await emitRunRecord(options, {
458
+ activation,
459
+ error: cause,
460
+ sourceId: registration.sourceId,
461
+ status: 'err',
462
+ trailId: registration.trail.id,
463
+ });
464
+ }
465
+ };
466
+
467
+ const mergeContextOverrides = (
468
+ base: TrailContextInit,
469
+ options: ScheduleRuntimeOptions
470
+ ): TrailContextInit => {
471
+ const withCtx =
472
+ options.ctx === undefined
473
+ ? base
474
+ : {
475
+ ...base,
476
+ ...options.ctx,
477
+ extensions: { ...base.extensions, ...options.ctx.extensions },
478
+ };
479
+
480
+ return options.abortSignal === undefined
481
+ ? withCtx
482
+ : { ...withCtx, abortSignal: options.abortSignal };
483
+ };
484
+
485
+ const createDrainContext = async (
486
+ options: ScheduleRuntimeOptions
487
+ ): Promise<TrailContext> => {
488
+ const seed =
489
+ options.createContext === undefined
490
+ ? undefined
491
+ : await options.createContext();
492
+ const base = createTrailContext(seed);
493
+ return createTrailContext(mergeContextOverrides(base, options));
494
+ };
495
+
496
+ const stopCronJobs = (
497
+ registrations: readonly RunningScheduleRegistration[]
498
+ ): Error[] => {
499
+ const failures: Error[] = [];
500
+ for (const registration of registrations) {
501
+ try {
502
+ registration.job.stop();
503
+ } catch (error) {
504
+ failures.push(errorFromUnknown(error));
505
+ }
506
+ }
507
+ return failures;
508
+ };
509
+
510
+ const startFailure = (
511
+ registration: ScheduleActivationRegistration,
512
+ error: unknown
513
+ ): InternalError => {
514
+ const cause = errorFromUnknown(error);
515
+ return new InternalError(
516
+ `Schedule source "${registration.sourceId}" failed to register for trail "${registration.trail.id}": ${cause.message}`,
517
+ {
518
+ cause,
519
+ context: { ...registrationReport(registration) },
520
+ }
521
+ );
522
+ };
523
+
524
+ const stopFailure = (
525
+ failures: readonly Error[],
526
+ report: Omit<ScheduleRuntimeStopReport, 'resources'>,
527
+ drainError?: Error
528
+ ): InternalError => {
529
+ const primary = failures[0] ?? drainError;
530
+ return new InternalError('Schedule runtime stop failed', {
531
+ ...(primary === undefined ? {} : { cause: primary }),
532
+ context: {
533
+ failures: [
534
+ ...failures.map(errorRecord),
535
+ ...(drainError === undefined ? [] : [errorRecord(drainError)]),
536
+ ],
537
+ settledRuns: report.settledRuns,
538
+ stopped: report.stopped,
539
+ },
540
+ });
541
+ };
542
+
543
+ export const createScheduleRuntime = (
544
+ graph: Topo,
545
+ options: ScheduleRuntimeOptions = {}
546
+ ): ScheduleRuntime => {
547
+ const cron = options.cron ?? defaultCronFactory;
548
+ const inFlight = new Set<Promise<void>>();
549
+ let state: ScheduleRuntimeState = 'idle';
550
+ let running: RunningScheduleRegistration[] = [];
551
+
552
+ const runAndTrack = (
553
+ registration: ScheduleActivationRegistration
554
+ ): Promise<void> => {
555
+ const activation = scheduleActivationProvenance(registration);
556
+ if (state !== 'running') {
557
+ return emitRunRecord(options, {
558
+ activation,
559
+ skipReason: 'stopped',
560
+ sourceId: registration.sourceId,
561
+ status: 'skipped',
562
+ trailId: registration.trail.id,
563
+ });
564
+ }
565
+
566
+ const execution = executeScheduleActivation(
567
+ graph,
568
+ registration,
569
+ options,
570
+ activation
571
+ );
572
+ inFlight.add(execution);
573
+ void (async () => {
574
+ try {
575
+ await execution;
576
+ } finally {
577
+ inFlight.delete(execution);
578
+ }
579
+ })();
580
+ return execution;
581
+ };
582
+
583
+ const start = async (): Promise<
584
+ Result<ScheduleRuntimeStartReport, Error>
585
+ > => {
586
+ if (state !== 'idle') {
587
+ return Result.err(
588
+ new ConflictError(`Schedule runtime cannot start from state "${state}"`)
589
+ );
590
+ }
591
+
592
+ const validated = validateTopo(graph);
593
+ if (validated.isErr()) {
594
+ return validated;
595
+ }
596
+
597
+ const registrations = collectScheduleActivations(graph);
598
+ const started: RunningScheduleRegistration[] = [];
599
+ state = 'running';
600
+
601
+ for (const registration of registrations) {
602
+ try {
603
+ const job = cron(registration.cron, () => runAndTrack(registration));
604
+ if (options.unref === true) {
605
+ job.unref?.();
606
+ }
607
+ started.push({ ...registration, job });
608
+ } catch (error) {
609
+ state = 'stopping';
610
+ stopCronJobs(started);
611
+ state = 'idle';
612
+ running = [];
613
+ return Result.err(startFailure(registration, error));
614
+ }
615
+ }
616
+
617
+ running = started;
618
+ const warnings = collectStartWarnings(running);
619
+ logStartWarnings(warnings, options.logger);
620
+ return Result.ok({
621
+ registered: running.map(registrationReport),
622
+ warnings,
623
+ });
624
+ };
625
+
626
+ const stop = async (): Promise<Result<ScheduleRuntimeStopReport, Error>> => {
627
+ if (state === 'idle' || state === 'stopped') {
628
+ return Result.ok({
629
+ resources: { disposed: [], evicted: [] },
630
+ settledRuns: 0,
631
+ stopped: [],
632
+ });
633
+ }
634
+ if (state === 'stopping') {
635
+ return Result.err(
636
+ new ConflictError('Schedule runtime stop is already in progress')
637
+ );
638
+ }
639
+
640
+ state = 'stopping';
641
+ const stopping = running;
642
+ const stopped = stopping.map(registrationReport);
643
+ const stopFailures = stopCronJobs(stopping);
644
+ const pending = [...inFlight];
645
+ const settled = await Promise.allSettled(pending);
646
+ const rejectedRuns = settled
647
+ .filter((entry) => entry.status === 'rejected')
648
+ .map((entry) => errorFromUnknown(entry.reason));
649
+
650
+ const partialReport = { settledRuns: pending.length, stopped };
651
+ const failures = [...stopFailures, ...rejectedRuns];
652
+ const drained = await (async () => {
653
+ try {
654
+ const drainCtx = await createDrainContext(options);
655
+ return await drainResources(
656
+ graph.listResources(),
657
+ drainCtx,
658
+ options.configValues
659
+ );
660
+ } catch (error) {
661
+ return Result.err(errorFromUnknown(error));
662
+ }
663
+ })();
664
+
665
+ running = [];
666
+ state = 'stopped';
667
+
668
+ if (drained.isErr() || failures.length > 0) {
669
+ return Result.err(
670
+ stopFailure(
671
+ failures,
672
+ partialReport,
673
+ drained.isErr() ? drained.error : undefined
674
+ )
675
+ );
676
+ }
677
+
678
+ return Result.ok({
679
+ resources: drained.value,
680
+ ...partialReport,
681
+ });
682
+ };
683
+
684
+ return Object.freeze({
685
+ start,
686
+ state: () => state,
687
+ stop,
688
+ });
689
+ };