@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,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
|
+
};
|