duron 0.3.0-beta.8 → 0.3.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/dist/action-job.d.ts +33 -2
- package/dist/action-job.d.ts.map +1 -1
- package/dist/action-job.js +93 -26
- package/dist/action-manager.d.ts +44 -2
- package/dist/action-manager.d.ts.map +1 -1
- package/dist/action-manager.js +64 -3
- package/dist/action.d.ts +388 -7
- package/dist/action.d.ts.map +1 -1
- package/dist/action.js +44 -23
- package/dist/adapters/adapter.d.ts +365 -8
- package/dist/adapters/adapter.d.ts.map +1 -1
- package/dist/adapters/adapter.js +221 -15
- package/dist/adapters/postgres/base.d.ts +184 -6
- package/dist/adapters/postgres/base.d.ts.map +1 -1
- package/dist/adapters/postgres/base.js +436 -75
- package/dist/adapters/postgres/pglite.d.ts +37 -0
- package/dist/adapters/postgres/pglite.d.ts.map +1 -1
- package/dist/adapters/postgres/pglite.js +38 -0
- package/dist/adapters/postgres/postgres.d.ts +35 -0
- package/dist/adapters/postgres/postgres.d.ts.map +1 -1
- package/dist/adapters/postgres/postgres.js +42 -0
- package/dist/adapters/postgres/schema.d.ts +150 -37
- package/dist/adapters/postgres/schema.d.ts.map +1 -1
- package/dist/adapters/postgres/schema.default.d.ts +151 -38
- package/dist/adapters/postgres/schema.default.d.ts.map +1 -1
- package/dist/adapters/postgres/schema.default.js +2 -2
- package/dist/adapters/postgres/schema.js +60 -23
- package/dist/adapters/schemas.d.ts +124 -80
- package/dist/adapters/schemas.d.ts.map +1 -1
- package/dist/adapters/schemas.js +139 -26
- package/dist/client.d.ts +426 -22
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +370 -20
- package/dist/constants.js +6 -0
- package/dist/errors.d.ts +166 -9
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +189 -19
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/server.d.ts +99 -37
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +84 -25
- package/dist/step-manager.d.ts +111 -4
- package/dist/step-manager.d.ts.map +1 -1
- package/dist/step-manager.js +411 -75
- package/dist/telemetry/index.d.ts +1 -4
- package/dist/telemetry/index.d.ts.map +1 -1
- package/dist/telemetry/index.js +2 -4
- package/dist/telemetry/local-span-exporter.d.ts +56 -0
- package/dist/telemetry/local-span-exporter.d.ts.map +1 -0
- package/dist/telemetry/local-span-exporter.js +118 -0
- package/dist/utils/p-retry.d.ts +5 -0
- package/dist/utils/p-retry.d.ts.map +1 -1
- package/dist/utils/p-retry.js +8 -0
- package/dist/utils/wait-for-abort.d.ts +1 -0
- package/dist/utils/wait-for-abort.d.ts.map +1 -1
- package/dist/utils/wait-for-abort.js +1 -0
- package/migrations/postgres/{20260119153838_flimsy_thor_girl → 20260121160012_normal_bloodstrike}/migration.sql +32 -20
- package/migrations/postgres/{20260119153838_flimsy_thor_girl → 20260121160012_normal_bloodstrike}/snapshot.json +241 -66
- package/package.json +42 -26
- package/src/action-job.ts +43 -32
- package/src/action-manager.ts +5 -5
- package/src/action.ts +317 -149
- package/src/adapters/adapter.ts +54 -54
- package/src/adapters/postgres/base.ts +266 -86
- package/src/adapters/postgres/schema.default.ts +2 -2
- package/src/adapters/postgres/schema.ts +52 -24
- package/src/adapters/schemas.ts +91 -36
- package/src/client.ts +322 -68
- package/src/errors.ts +141 -30
- package/src/index.ts +2 -0
- package/src/server.ts +39 -37
- package/src/step-manager.ts +254 -91
- package/src/telemetry/index.ts +2 -20
- package/src/telemetry/local-span-exporter.ts +148 -0
- package/dist/telemetry/adapter.d.ts +0 -107
- package/dist/telemetry/adapter.d.ts.map +0 -1
- package/dist/telemetry/adapter.js +0 -134
- package/dist/telemetry/local.d.ts +0 -22
- package/dist/telemetry/local.d.ts.map +0 -1
- package/dist/telemetry/local.js +0 -243
- package/dist/telemetry/noop.d.ts +0 -17
- package/dist/telemetry/noop.d.ts.map +0 -1
- package/dist/telemetry/noop.js +0 -66
- package/dist/telemetry/opentelemetry.d.ts +0 -25
- package/dist/telemetry/opentelemetry.d.ts.map +0 -1
- package/dist/telemetry/opentelemetry.js +0 -312
- package/src/telemetry/adapter.ts +0 -642
- package/src/telemetry/local.ts +0 -429
- package/src/telemetry/noop.ts +0 -141
- package/src/telemetry/opentelemetry.ts +0 -453
package/dist/client.js
CHANGED
|
@@ -1,9 +1,14 @@
|
|
|
1
|
+
import { trace } from '@opentelemetry/api';
|
|
2
|
+
import { resourceFromAttributes } from '@opentelemetry/resources';
|
|
3
|
+
import { BatchSpanProcessor } from '@opentelemetry/sdk-trace-base';
|
|
4
|
+
import { NodeTracerProvider } from '@opentelemetry/sdk-trace-node';
|
|
5
|
+
import { ATTR_SERVICE_NAME } from '@opentelemetry/semantic-conventions';
|
|
1
6
|
import pino, {} from 'pino';
|
|
2
7
|
import { zocker } from 'zocker';
|
|
3
8
|
import * as z from 'zod';
|
|
4
9
|
import { ActionManager } from './action-manager.js';
|
|
5
10
|
import { JOB_STATUS_CANCELLED, JOB_STATUS_COMPLETED, JOB_STATUS_FAILED } from './constants.js';
|
|
6
|
-
import {
|
|
11
|
+
import { LocalSpanExporter } from './telemetry/local-span-exporter.js';
|
|
7
12
|
const BaseOptionsSchema = z.object({
|
|
8
13
|
id: z.string().optional(),
|
|
9
14
|
syncPattern: z.union([z.literal('pull'), z.literal('push'), z.literal('hybrid'), z.literal(false)]).default('hybrid'),
|
|
@@ -16,12 +21,23 @@ const BaseOptionsSchema = z.object({
|
|
|
16
21
|
multiProcessMode: z.boolean().default(false),
|
|
17
22
|
processTimeout: z.number().default(5 * 1000),
|
|
18
23
|
});
|
|
24
|
+
const _checkOptions = true;
|
|
25
|
+
/**
|
|
26
|
+
* Client is the main entry point for Duron.
|
|
27
|
+
* Manages job execution, action handling, and database operations.
|
|
28
|
+
*
|
|
29
|
+
* @template TActions - Record of action definitions keyed by action name
|
|
30
|
+
* @template TVariables - Type of variables available to actions
|
|
31
|
+
*/
|
|
19
32
|
export class Client {
|
|
20
33
|
#options;
|
|
21
34
|
#id;
|
|
22
35
|
#actions;
|
|
23
36
|
#database;
|
|
24
|
-
#
|
|
37
|
+
#tracerProvider = null;
|
|
38
|
+
#tracer;
|
|
39
|
+
#telemetryOptions = null;
|
|
40
|
+
#localSpansEnabled = false;
|
|
25
41
|
#variables;
|
|
26
42
|
#logger;
|
|
27
43
|
#started = false;
|
|
@@ -33,18 +49,68 @@ export class Client {
|
|
|
33
49
|
#mockInputSchemas = new Map();
|
|
34
50
|
#pendingJobWaits = new Map();
|
|
35
51
|
#jobStatusListenerSetup = false;
|
|
52
|
+
// ============================================================================
|
|
53
|
+
// Constructor
|
|
54
|
+
// ============================================================================
|
|
55
|
+
/**
|
|
56
|
+
* Create a new Duron Client instance.
|
|
57
|
+
*
|
|
58
|
+
* @param options - Configuration options for the client
|
|
59
|
+
*/
|
|
36
60
|
constructor(options) {
|
|
37
61
|
this.#options = BaseOptionsSchema.parse(options);
|
|
38
62
|
this.#id = options.id ?? globalThis.crypto.randomUUID();
|
|
39
63
|
this.#database = options.database;
|
|
40
|
-
this.#
|
|
64
|
+
this.#telemetryOptions = options.telemetry ?? null;
|
|
41
65
|
this.#actions = options.actions ?? null;
|
|
42
66
|
this.#variables = options?.variables ?? {};
|
|
43
67
|
this.#logger = this.#normalizeLogger(options?.logger);
|
|
44
68
|
this.#database.setId(this.#id);
|
|
45
69
|
this.#database.setLogger(this.#logger);
|
|
46
|
-
|
|
47
|
-
|
|
70
|
+
// Initialize OpenTelemetry TracerProvider if telemetry options are provided
|
|
71
|
+
// When no options are provided, the tracer will be a no-op (from OpenTelemetry API)
|
|
72
|
+
if (this.#telemetryOptions) {
|
|
73
|
+
this.#initTelemetry(this.#telemetryOptions);
|
|
74
|
+
}
|
|
75
|
+
// Get tracer from our provider if configured, otherwise use global no-op tracer
|
|
76
|
+
// This keeps telemetry scoped to this client instance rather than globally registered
|
|
77
|
+
this.#tracer = this.#tracerProvider?.getTracer('duron') ?? trace.getTracer('duron');
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Initialize OpenTelemetry TracerProvider with configured processors.
|
|
81
|
+
*/
|
|
82
|
+
#initTelemetry(options) {
|
|
83
|
+
const serviceName = options.serviceName ?? 'duron';
|
|
84
|
+
const processors = [];
|
|
85
|
+
// Add local span exporter if enabled
|
|
86
|
+
if (options.local) {
|
|
87
|
+
const localOptions = typeof options.local === 'boolean' ? {} : options.local;
|
|
88
|
+
const flushDelayMs = localOptions.flushDelayMs ?? 5000;
|
|
89
|
+
const localExporter = new LocalSpanExporter({ adapter: this.#database });
|
|
90
|
+
processors.push(new BatchSpanProcessor(localExporter, {
|
|
91
|
+
scheduledDelayMillis: flushDelayMs,
|
|
92
|
+
}));
|
|
93
|
+
this.#localSpansEnabled = true;
|
|
94
|
+
}
|
|
95
|
+
// Add custom span processors
|
|
96
|
+
if (options.spanProcessors) {
|
|
97
|
+
processors.push(...options.spanProcessors);
|
|
98
|
+
}
|
|
99
|
+
// Add custom trace exporter wrapped in BatchSpanProcessor
|
|
100
|
+
if (options.traceExporter) {
|
|
101
|
+
processors.push(new BatchSpanProcessor(options.traceExporter));
|
|
102
|
+
}
|
|
103
|
+
// Only create TracerProvider if we have processors
|
|
104
|
+
if (processors.length > 0) {
|
|
105
|
+
this.#tracerProvider = new NodeTracerProvider({
|
|
106
|
+
resource: resourceFromAttributes({
|
|
107
|
+
[ATTR_SERVICE_NAME]: serviceName,
|
|
108
|
+
}),
|
|
109
|
+
spanProcessors: processors,
|
|
110
|
+
});
|
|
111
|
+
// Note: We do NOT call .register() here to avoid global state pollution
|
|
112
|
+
// The tracer is obtained directly from this provider instance
|
|
113
|
+
}
|
|
48
114
|
}
|
|
49
115
|
#normalizeLogger(logger) {
|
|
50
116
|
let pinoInstance = null;
|
|
@@ -59,18 +125,46 @@ export class Client {
|
|
|
59
125
|
}
|
|
60
126
|
return pinoInstance.child({ duron: this.#id });
|
|
61
127
|
}
|
|
128
|
+
// ============================================================================
|
|
129
|
+
// Public API Methods
|
|
130
|
+
// ============================================================================
|
|
62
131
|
get logger() {
|
|
63
132
|
return this.#logger;
|
|
64
133
|
}
|
|
65
|
-
|
|
66
|
-
|
|
134
|
+
/**
|
|
135
|
+
* Get the OpenTelemetry tracer for creating custom spans.
|
|
136
|
+
* Always returns a tracer - it's a no-op tracer when no SDK is configured.
|
|
137
|
+
*/
|
|
138
|
+
get tracer() {
|
|
139
|
+
return this.#tracer;
|
|
67
140
|
}
|
|
141
|
+
/**
|
|
142
|
+
* Get the database adapter instance.
|
|
143
|
+
*/
|
|
68
144
|
get database() {
|
|
69
145
|
return this.#database;
|
|
70
146
|
}
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
147
|
+
/**
|
|
148
|
+
* Check if local span storage is enabled.
|
|
149
|
+
* Returns true if telemetry.local is enabled.
|
|
150
|
+
*/
|
|
151
|
+
get spansEnabled() {
|
|
152
|
+
return this.#localSpansEnabled;
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Force flush any pending telemetry data.
|
|
156
|
+
* Useful in tests or when you need to ensure spans are exported before querying.
|
|
157
|
+
*/
|
|
158
|
+
async flushTelemetry() {
|
|
159
|
+
if (this.#tracerProvider) {
|
|
160
|
+
await this.#tracerProvider.forceFlush();
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Get the current configuration of this Duron instance.
|
|
165
|
+
*
|
|
166
|
+
* @returns Configuration object including options, actions, and variables
|
|
167
|
+
*/
|
|
74
168
|
getConfig() {
|
|
75
169
|
return {
|
|
76
170
|
...this.#options,
|
|
@@ -78,12 +172,21 @@ export class Client {
|
|
|
78
172
|
variables: this.#variables,
|
|
79
173
|
};
|
|
80
174
|
}
|
|
175
|
+
/**
|
|
176
|
+
* Run an action by creating a new job.
|
|
177
|
+
*
|
|
178
|
+
* @param actionName - Name of the action to run
|
|
179
|
+
* @param input - Input data for the action (validated against action's input schema if provided)
|
|
180
|
+
* @returns Promise resolving to the created job ID
|
|
181
|
+
* @throws Error if action is not found or job creation fails
|
|
182
|
+
*/
|
|
81
183
|
async runAction(actionName, input) {
|
|
82
184
|
await this.start();
|
|
83
185
|
const action = this.#actions?.[actionName];
|
|
84
186
|
if (!action) {
|
|
85
187
|
throw new Error(`Action ${String(actionName)} not found`);
|
|
86
188
|
}
|
|
189
|
+
// Validate input if schema is provided
|
|
87
190
|
let validatedInput = input ?? {};
|
|
88
191
|
if (action.input) {
|
|
89
192
|
validatedInput = action.input.parse(validatedInput, {
|
|
@@ -91,6 +194,7 @@ export class Client {
|
|
|
91
194
|
reportInput: true,
|
|
92
195
|
});
|
|
93
196
|
}
|
|
197
|
+
// Determine groupKey and concurrency limit using concurrency handler or defaults
|
|
94
198
|
const concurrencyCtx = {
|
|
95
199
|
input: validatedInput,
|
|
96
200
|
var: this.#variables,
|
|
@@ -103,6 +207,12 @@ export class Client {
|
|
|
103
207
|
if (action.groups?.concurrency) {
|
|
104
208
|
concurrencyLimit = await action.groups.concurrency(concurrencyCtx);
|
|
105
209
|
}
|
|
210
|
+
// Calculate description if provided
|
|
211
|
+
let description = null;
|
|
212
|
+
if (action.description) {
|
|
213
|
+
description = await action.description(concurrencyCtx);
|
|
214
|
+
}
|
|
215
|
+
// Create job in database
|
|
106
216
|
const jobId = await this.#database.createJob({
|
|
107
217
|
queue: action.name,
|
|
108
218
|
groupKey,
|
|
@@ -110,6 +220,8 @@ export class Client {
|
|
|
110
220
|
timeoutMs: action.expire,
|
|
111
221
|
checksum: action.checksum,
|
|
112
222
|
concurrencyLimit,
|
|
223
|
+
concurrencyStepLimit: action.steps.concurrency,
|
|
224
|
+
description,
|
|
113
225
|
});
|
|
114
226
|
if (!jobId) {
|
|
115
227
|
throw new Error(`Failed to create job for action ${String(actionName)}`);
|
|
@@ -117,11 +229,24 @@ export class Client {
|
|
|
117
229
|
this.#logger.debug({ jobId, actionName: String(actionName), groupKey }, '[Duron] Action sent/created');
|
|
118
230
|
return jobId;
|
|
119
231
|
}
|
|
232
|
+
/**
|
|
233
|
+
* Run an action and wait for its completion.
|
|
234
|
+
* This is a convenience method that combines `runAction` and `waitForJob`.
|
|
235
|
+
*
|
|
236
|
+
* @param actionName - Name of the action to run
|
|
237
|
+
* @param input - Input data for the action (validated against action's input schema if provided)
|
|
238
|
+
* @param options - Options including abort signal and timeout
|
|
239
|
+
* @returns Promise resolving to the job result with typed input and output
|
|
240
|
+
* @throws Error if action is not found, job creation fails, job is cancelled, or operation is aborted
|
|
241
|
+
*/
|
|
120
242
|
async runActionAndWait(actionName, input, options) {
|
|
243
|
+
// Check if already aborted before starting
|
|
121
244
|
if (options?.signal?.aborted) {
|
|
122
245
|
throw new Error('Operation was aborted');
|
|
123
246
|
}
|
|
247
|
+
// Create the job
|
|
124
248
|
const jobId = await this.runAction(actionName, input);
|
|
249
|
+
// Set up abort handler to cancel the job if signal is aborted
|
|
125
250
|
let abortHandler;
|
|
126
251
|
if (options?.signal) {
|
|
127
252
|
abortHandler = () => {
|
|
@@ -131,6 +256,7 @@ export class Client {
|
|
|
131
256
|
};
|
|
132
257
|
options.signal.addEventListener('abort', abortHandler, { once: true });
|
|
133
258
|
}
|
|
259
|
+
// Set up timeout handler to cancel the job if timeout is reached
|
|
134
260
|
let timeoutId;
|
|
135
261
|
let timeoutAbortController;
|
|
136
262
|
if (options?.timeout) {
|
|
@@ -143,6 +269,7 @@ export class Client {
|
|
|
143
269
|
}, options.timeout);
|
|
144
270
|
}
|
|
145
271
|
try {
|
|
272
|
+
// Combine signals if both are provided
|
|
146
273
|
let waitSignal;
|
|
147
274
|
if (options?.signal && timeoutAbortController) {
|
|
148
275
|
waitSignal = AbortSignal.any([options.signal, timeoutAbortController.signal]);
|
|
@@ -153,13 +280,16 @@ export class Client {
|
|
|
153
280
|
else if (timeoutAbortController) {
|
|
154
281
|
waitSignal = timeoutAbortController.signal;
|
|
155
282
|
}
|
|
283
|
+
// Wait for the job to complete
|
|
156
284
|
const job = await this.waitForJob(jobId, { signal: waitSignal });
|
|
285
|
+
// Clean up
|
|
157
286
|
if (timeoutId) {
|
|
158
287
|
clearTimeout(timeoutId);
|
|
159
288
|
}
|
|
160
289
|
if (options?.signal && abortHandler) {
|
|
161
290
|
options.signal.removeEventListener('abort', abortHandler);
|
|
162
291
|
}
|
|
292
|
+
// Handle null result (aborted or timed out)
|
|
163
293
|
if (!job) {
|
|
164
294
|
if (options?.signal?.aborted) {
|
|
165
295
|
throw new Error('Operation was aborted');
|
|
@@ -169,6 +299,7 @@ export class Client {
|
|
|
169
299
|
}
|
|
170
300
|
throw new Error('Job not found');
|
|
171
301
|
}
|
|
302
|
+
// Handle cancelled job
|
|
172
303
|
if (job.status === JOB_STATUS_CANCELLED) {
|
|
173
304
|
if (options?.signal?.aborted) {
|
|
174
305
|
throw new Error('Operation was aborted');
|
|
@@ -178,6 +309,7 @@ export class Client {
|
|
|
178
309
|
}
|
|
179
310
|
throw new Error('Job was cancelled');
|
|
180
311
|
}
|
|
312
|
+
// Handle failed job
|
|
181
313
|
if (job.status === JOB_STATUS_FAILED) {
|
|
182
314
|
const errorMessage = job.error?.message ?? 'Job failed';
|
|
183
315
|
const error = new Error(errorMessage);
|
|
@@ -186,9 +318,11 @@ export class Client {
|
|
|
186
318
|
}
|
|
187
319
|
throw error;
|
|
188
320
|
}
|
|
321
|
+
// Return the job result with typed input/output
|
|
189
322
|
return job;
|
|
190
323
|
}
|
|
191
324
|
catch (err) {
|
|
325
|
+
// Clean up on error
|
|
192
326
|
if (timeoutId) {
|
|
193
327
|
clearTimeout(timeoutId);
|
|
194
328
|
}
|
|
@@ -198,19 +332,37 @@ export class Client {
|
|
|
198
332
|
throw err;
|
|
199
333
|
}
|
|
200
334
|
}
|
|
201
|
-
|
|
335
|
+
/**
|
|
336
|
+
* Fetch and process jobs from the database.
|
|
337
|
+
* Concurrency limits are determined from the latest job created for each groupKey.
|
|
338
|
+
*
|
|
339
|
+
* @param [options.batchSize] - Maximum number of jobs to fetch in this batch (defaults to `batchSize` from client options)
|
|
340
|
+
* @returns Promise resolving to the array of fetched jobs
|
|
341
|
+
*/
|
|
342
|
+
async fetch(options = {}) {
|
|
202
343
|
await this.start();
|
|
203
344
|
if (!this.#actions) {
|
|
204
345
|
return [];
|
|
205
346
|
}
|
|
347
|
+
// Fetch jobs from each action's queue
|
|
348
|
+
// Concurrency limits are determined from the latest job created for each groupKey
|
|
206
349
|
const jobs = await this.#database.fetch({
|
|
207
350
|
batch: options.batchSize ?? this.#options.batchSize,
|
|
208
351
|
});
|
|
352
|
+
// Process fetched jobs
|
|
209
353
|
for (const job of jobs) {
|
|
210
354
|
this.#executeJob(job);
|
|
211
355
|
}
|
|
212
356
|
return jobs;
|
|
213
357
|
}
|
|
358
|
+
/**
|
|
359
|
+
* Cancel a job by its ID.
|
|
360
|
+
* If the job is currently being processed, it will be cancelled immediately.
|
|
361
|
+
* Otherwise, it will be cancelled in the database.
|
|
362
|
+
*
|
|
363
|
+
* @param jobId - The ID of the job to cancel
|
|
364
|
+
* @returns Promise resolving to `true` if cancelled, `false` otherwise
|
|
365
|
+
*/
|
|
214
366
|
async cancelJob(jobId) {
|
|
215
367
|
await this.start();
|
|
216
368
|
let cancelled = false;
|
|
@@ -221,52 +373,134 @@ export class Client {
|
|
|
221
373
|
}
|
|
222
374
|
}
|
|
223
375
|
if (!cancelled) {
|
|
376
|
+
// If the job is not being processed, cancel it in the database
|
|
224
377
|
await this.#database.cancelJob({ jobId });
|
|
225
378
|
}
|
|
226
379
|
return cancelled;
|
|
227
380
|
}
|
|
381
|
+
/**
|
|
382
|
+
* Retry a failed job by creating a copy of it with status 'created' and cleared output/error.
|
|
383
|
+
*
|
|
384
|
+
* @param jobId - The ID of the job to retry
|
|
385
|
+
* @returns Promise resolving to the new job ID, or `null` if retry failed
|
|
386
|
+
*/
|
|
228
387
|
async retryJob(jobId) {
|
|
229
388
|
await this.start();
|
|
230
389
|
return this.#database.retryJob({ jobId });
|
|
231
390
|
}
|
|
391
|
+
/**
|
|
392
|
+
* Time travel a job to restart from a specific step.
|
|
393
|
+
* The job must be in completed, failed, or cancelled status.
|
|
394
|
+
* Resets the job and ancestor steps to active status, deletes subsequent steps,
|
|
395
|
+
* and preserves completed parallel siblings.
|
|
396
|
+
*
|
|
397
|
+
* @param jobId - The ID of the job to time travel
|
|
398
|
+
* @param stepId - The ID of the step to restart from
|
|
399
|
+
* @returns Promise resolving to `true` if time travel succeeded, `false` otherwise
|
|
400
|
+
*/
|
|
232
401
|
async timeTravelJob(jobId, stepId) {
|
|
233
402
|
await this.start();
|
|
234
403
|
return this.#database.timeTravelJob({ jobId, stepId });
|
|
235
404
|
}
|
|
405
|
+
/**
|
|
406
|
+
* Delete a job by its ID.
|
|
407
|
+
* Active jobs cannot be deleted.
|
|
408
|
+
*
|
|
409
|
+
* @param jobId - The ID of the job to delete
|
|
410
|
+
* @returns Promise resolving to `true` if deleted, `false` otherwise
|
|
411
|
+
*/
|
|
236
412
|
async deleteJob(jobId) {
|
|
237
413
|
await this.start();
|
|
238
414
|
return this.#database.deleteJob({ jobId });
|
|
239
415
|
}
|
|
416
|
+
/**
|
|
417
|
+
* Delete multiple jobs using the same filters as getJobs.
|
|
418
|
+
* Active jobs cannot be deleted and will be excluded from deletion.
|
|
419
|
+
*
|
|
420
|
+
* @param options - Query options including filters (same as getJobs)
|
|
421
|
+
* @returns Promise resolving to the number of jobs deleted
|
|
422
|
+
*/
|
|
240
423
|
async deleteJobs(options) {
|
|
241
424
|
await this.start();
|
|
242
425
|
return this.#database.deleteJobs(options);
|
|
243
426
|
}
|
|
427
|
+
// ============================================================================
|
|
428
|
+
// Query Methods
|
|
429
|
+
// ============================================================================
|
|
430
|
+
/**
|
|
431
|
+
* Get a job by its ID. Does not include step information.
|
|
432
|
+
*
|
|
433
|
+
* @param jobId - The ID of the job to retrieve
|
|
434
|
+
* @returns Promise resolving to the job, or `null` if not found
|
|
435
|
+
*/
|
|
244
436
|
async getJobById(jobId) {
|
|
245
437
|
await this.start();
|
|
246
438
|
return this.#database.getJobById(jobId);
|
|
247
439
|
}
|
|
440
|
+
/**
|
|
441
|
+
* Get steps for a job with pagination and fuzzy search.
|
|
442
|
+
* Steps are always ordered by created_at ASC.
|
|
443
|
+
* Steps do not include output data.
|
|
444
|
+
*
|
|
445
|
+
* @param options - Query options including jobId, pagination, and search
|
|
446
|
+
* @returns Promise resolving to steps result with pagination info
|
|
447
|
+
*/
|
|
248
448
|
async getJobSteps(options) {
|
|
249
449
|
await this.start();
|
|
250
450
|
return this.#database.getJobSteps(options);
|
|
251
451
|
}
|
|
452
|
+
/**
|
|
453
|
+
* Get jobs with pagination, filtering, and sorting.
|
|
454
|
+
* Does not include step information or job output.
|
|
455
|
+
*
|
|
456
|
+
* @param options - Query options including pagination, filters, and sort
|
|
457
|
+
* @returns Promise resolving to jobs result with pagination info
|
|
458
|
+
*/
|
|
252
459
|
async getJobs(options) {
|
|
253
460
|
await this.start();
|
|
254
461
|
return this.#database.getJobs(options);
|
|
255
462
|
}
|
|
463
|
+
/**
|
|
464
|
+
* Get a step by its ID with all information.
|
|
465
|
+
*
|
|
466
|
+
* @param stepId - The ID of the step to retrieve
|
|
467
|
+
* @returns Promise resolving to the step, or `null` if not found
|
|
468
|
+
*/
|
|
256
469
|
async getJobStepById(stepId) {
|
|
257
470
|
await this.start();
|
|
258
471
|
return this.#database.getJobStepById(stepId);
|
|
259
472
|
}
|
|
473
|
+
/**
|
|
474
|
+
* Get job status and updatedAt timestamp.
|
|
475
|
+
*
|
|
476
|
+
* @param jobId - The ID of the job
|
|
477
|
+
* @returns Promise resolving to job status result, or `null` if not found
|
|
478
|
+
*/
|
|
260
479
|
async getJobStatus(jobId) {
|
|
261
480
|
await this.start();
|
|
262
481
|
return this.#database.getJobStatus(jobId);
|
|
263
482
|
}
|
|
483
|
+
/**
|
|
484
|
+
* Get job step status and updatedAt timestamp.
|
|
485
|
+
*
|
|
486
|
+
* @param stepId - The ID of the step
|
|
487
|
+
* @returns Promise resolving to step status result, or `null` if not found
|
|
488
|
+
*/
|
|
264
489
|
async getJobStepStatus(stepId) {
|
|
265
490
|
await this.start();
|
|
266
491
|
return this.#database.getJobStepStatus(stepId);
|
|
267
492
|
}
|
|
493
|
+
/**
|
|
494
|
+
* Wait for a job to change status by subscribing to job-status-changed events.
|
|
495
|
+
* When the job status changes, the job result is returned.
|
|
496
|
+
*
|
|
497
|
+
* @param jobId - The ID of the job to wait for
|
|
498
|
+
* @param options - Optional configuration including timeout
|
|
499
|
+
* @returns Promise resolving to the job result when its status changes, or `null` if timeout
|
|
500
|
+
*/
|
|
268
501
|
async waitForJob(jobId, options) {
|
|
269
502
|
await this.start();
|
|
503
|
+
// First, check if the job already exists and is in a terminal state
|
|
270
504
|
const existingJobStatus = await this.getJobStatus(jobId);
|
|
271
505
|
if (existingJobStatus) {
|
|
272
506
|
const terminalStatuses = [JOB_STATUS_COMPLETED, JOB_STATUS_FAILED, JOB_STATUS_CANCELLED];
|
|
@@ -276,30 +510,35 @@ export class Client {
|
|
|
276
510
|
return null;
|
|
277
511
|
}
|
|
278
512
|
return {
|
|
279
|
-
|
|
513
|
+
id: job.id,
|
|
280
514
|
actionName: job.actionName,
|
|
281
515
|
status: job.status,
|
|
282
516
|
groupKey: job.groupKey,
|
|
517
|
+
description: job.description,
|
|
283
518
|
input: job.input,
|
|
284
519
|
output: job.output,
|
|
285
520
|
error: job.error,
|
|
286
521
|
};
|
|
287
522
|
}
|
|
288
523
|
}
|
|
524
|
+
// Set up the shared event listener if not already set up
|
|
289
525
|
this.#setupJobStatusListener();
|
|
290
526
|
return new Promise((resolve) => {
|
|
527
|
+
// Check if already aborted before setting up wait
|
|
291
528
|
if (options?.signal?.aborted) {
|
|
292
529
|
resolve(null);
|
|
293
530
|
return;
|
|
294
531
|
}
|
|
295
532
|
let timeoutId;
|
|
296
533
|
let abortHandler;
|
|
534
|
+
// Set up timeout if provided
|
|
297
535
|
if (options?.timeout) {
|
|
298
536
|
timeoutId = setTimeout(() => {
|
|
299
537
|
this.#removeJobWait(jobId, resolve);
|
|
300
538
|
resolve(null);
|
|
301
539
|
}, options.timeout);
|
|
302
540
|
}
|
|
541
|
+
// Set up abort signal if provided
|
|
303
542
|
if (options?.signal) {
|
|
304
543
|
abortHandler = () => {
|
|
305
544
|
this.#removeJobWait(jobId, resolve);
|
|
@@ -307,6 +546,7 @@ export class Client {
|
|
|
307
546
|
};
|
|
308
547
|
options.signal.addEventListener('abort', abortHandler);
|
|
309
548
|
}
|
|
549
|
+
// Add this wait request to the pending waits
|
|
310
550
|
if (!this.#pendingJobWaits.has(jobId)) {
|
|
311
551
|
this.#pendingJobWaits.set(jobId, new Set());
|
|
312
552
|
}
|
|
@@ -318,17 +558,36 @@ export class Client {
|
|
|
318
558
|
});
|
|
319
559
|
});
|
|
320
560
|
}
|
|
561
|
+
/**
|
|
562
|
+
* Get action statistics including counts and last job created date.
|
|
563
|
+
*
|
|
564
|
+
* @returns Promise resolving to action statistics
|
|
565
|
+
*/
|
|
321
566
|
async getActions() {
|
|
322
567
|
await this.start();
|
|
323
568
|
return this.#database.getActions();
|
|
324
569
|
}
|
|
325
|
-
|
|
570
|
+
/**
|
|
571
|
+
* Get spans for a job or step.
|
|
572
|
+
* Only available when telemetry.local is enabled.
|
|
573
|
+
*
|
|
574
|
+
* @param options - Query options including jobId/stepId, filters, and sort
|
|
575
|
+
* @returns Promise resolving to spans result
|
|
576
|
+
* @throws Error if local telemetry is not enabled
|
|
577
|
+
*/
|
|
578
|
+
async getSpans(options) {
|
|
326
579
|
await this.start();
|
|
327
|
-
if (!this.
|
|
328
|
-
throw new Error('
|
|
580
|
+
if (!this.spansEnabled) {
|
|
581
|
+
throw new Error('Spans are only available when telemetry.local is enabled');
|
|
329
582
|
}
|
|
330
|
-
return this.#database.
|
|
583
|
+
return this.#database.getSpans(options);
|
|
331
584
|
}
|
|
585
|
+
/**
|
|
586
|
+
* Get action metadata including input schemas and mock data.
|
|
587
|
+
* This is useful for generating UI forms or mock data.
|
|
588
|
+
*
|
|
589
|
+
* @returns Promise resolving to action metadata
|
|
590
|
+
*/
|
|
332
591
|
async getActionsMetadata() {
|
|
333
592
|
await this.start();
|
|
334
593
|
if (!this.#actions) {
|
|
@@ -340,6 +599,39 @@ export class Client {
|
|
|
340
599
|
if (!this.#mockInputSchemas.has(action.name)) {
|
|
341
600
|
this.#mockInputSchemas.set(action.name, zocker(action.input)
|
|
342
601
|
.override(z.ZodString, 'string')
|
|
602
|
+
.override(z.ZodBigInt, '4000') // Convert BigInt to string for JSON serialization
|
|
603
|
+
.override(z.ZodNumber, (schema, _ctx) => {
|
|
604
|
+
const greaterThan = schema.def.checks?.find((check) => check._zod.def.check === 'greater_than')?._zod
|
|
605
|
+
.def;
|
|
606
|
+
const lessThan = schema.def.checks?.find((check) => check._zod.def.check === 'less_than')?._zod
|
|
607
|
+
.def;
|
|
608
|
+
if (greaterThan && lessThan) {
|
|
609
|
+
const min = greaterThan.inclusive ? greaterThan.value : greaterThan.value + 1;
|
|
610
|
+
// For inclusive lessThan, we want to include the value, so max should be value + 1
|
|
611
|
+
// For exclusive lessThan, we want to exclude the value, so max is the value itself
|
|
612
|
+
const max = lessThan.inclusive ? lessThan.value + 1 : lessThan.value;
|
|
613
|
+
// Ensure min < max
|
|
614
|
+
if (min >= max) {
|
|
615
|
+
return Math.floor(min);
|
|
616
|
+
}
|
|
617
|
+
return Math.floor(Math.random() * (max - min) + min);
|
|
618
|
+
}
|
|
619
|
+
if (greaterThan) {
|
|
620
|
+
const min = greaterThan.inclusive ? greaterThan.value : greaterThan.value + 1;
|
|
621
|
+
const max = min + 1000; // Use 1000 as default range
|
|
622
|
+
return Math.floor(Math.random() * (max - min) + min);
|
|
623
|
+
}
|
|
624
|
+
if (lessThan) {
|
|
625
|
+
// For inclusive lessThan, we want to include the value, so max should be value + 1
|
|
626
|
+
// For exclusive lessThan, we want to exclude the value, so max is the value itself
|
|
627
|
+
const max = lessThan.inclusive ? lessThan.value + 1 : lessThan.value;
|
|
628
|
+
return Math.floor(Math.random() * max);
|
|
629
|
+
}
|
|
630
|
+
return Math.floor(Math.random() * 1000);
|
|
631
|
+
})
|
|
632
|
+
.number({
|
|
633
|
+
extreme_value_chance: 0.01,
|
|
634
|
+
})
|
|
343
635
|
.generate());
|
|
344
636
|
}
|
|
345
637
|
mockInput = this.#mockInputSchemas.get(action.name);
|
|
@@ -350,6 +642,15 @@ export class Client {
|
|
|
350
642
|
};
|
|
351
643
|
});
|
|
352
644
|
}
|
|
645
|
+
// ============================================================================
|
|
646
|
+
// Lifecycle Methods
|
|
647
|
+
// ============================================================================
|
|
648
|
+
/**
|
|
649
|
+
* Start the Duron instance.
|
|
650
|
+
* Initializes the database, recovers stuck jobs, and sets up sync patterns.
|
|
651
|
+
*
|
|
652
|
+
* @returns Promise resolving to `true` if started successfully, `false` otherwise
|
|
653
|
+
*/
|
|
353
654
|
async start() {
|
|
354
655
|
if (this.#stopping || this.#stopped) {
|
|
355
656
|
return false;
|
|
@@ -365,7 +666,6 @@ export class Client {
|
|
|
365
666
|
if (!dbStarted) {
|
|
366
667
|
return false;
|
|
367
668
|
}
|
|
368
|
-
await this.#telemetry.start();
|
|
369
669
|
if (this.#actions) {
|
|
370
670
|
if (this.#options.recoverJobsOnStart) {
|
|
371
671
|
await this.#database.recoverJobs({
|
|
@@ -374,6 +674,7 @@ export class Client {
|
|
|
374
674
|
processTimeout: this.#options.processTimeout,
|
|
375
675
|
});
|
|
376
676
|
}
|
|
677
|
+
// Setup sync pattern
|
|
377
678
|
if (this.#options.syncPattern === 'pull' || this.#options.syncPattern === 'hybrid') {
|
|
378
679
|
this.#startPullLoop();
|
|
379
680
|
}
|
|
@@ -387,6 +688,12 @@ export class Client {
|
|
|
387
688
|
})();
|
|
388
689
|
return this.#starting;
|
|
389
690
|
}
|
|
691
|
+
/**
|
|
692
|
+
* Stop the Duron instance.
|
|
693
|
+
* Stops the pull loop, aborts all running jobs, waits for queues to drain, and stops the database.
|
|
694
|
+
*
|
|
695
|
+
* @returns Promise resolving to `true` if stopped successfully, `false` otherwise
|
|
696
|
+
*/
|
|
390
697
|
async stop() {
|
|
391
698
|
if (this.#stopped) {
|
|
392
699
|
return true;
|
|
@@ -395,10 +702,12 @@ export class Client {
|
|
|
395
702
|
return this.#stopping;
|
|
396
703
|
}
|
|
397
704
|
this.#stopping = (async () => {
|
|
705
|
+
// Stop pull loop
|
|
398
706
|
if (this.#pullInterval) {
|
|
399
707
|
clearTimeout(this.#pullInterval);
|
|
400
708
|
this.#pullInterval = null;
|
|
401
709
|
}
|
|
710
|
+
// Clean up all pending job waits
|
|
402
711
|
for (const waits of this.#pendingJobWaits.values()) {
|
|
403
712
|
for (const wait of waits) {
|
|
404
713
|
if (wait.timeoutId) {
|
|
@@ -411,10 +720,14 @@ export class Client {
|
|
|
411
720
|
}
|
|
412
721
|
}
|
|
413
722
|
this.#pendingJobWaits.clear();
|
|
723
|
+
// Wait for action managers to drain
|
|
414
724
|
await Promise.all(Array.from(this.#actionManagers.values()).map(async (manager) => {
|
|
415
725
|
await manager.stop();
|
|
416
726
|
}));
|
|
417
|
-
|
|
727
|
+
// Shutdown TracerProvider if configured
|
|
728
|
+
if (this.#tracerProvider) {
|
|
729
|
+
await this.#tracerProvider.shutdown();
|
|
730
|
+
}
|
|
418
731
|
const dbStopped = await this.#database.stop();
|
|
419
732
|
if (!dbStopped) {
|
|
420
733
|
return false;
|
|
@@ -425,6 +738,13 @@ export class Client {
|
|
|
425
738
|
})();
|
|
426
739
|
return this.#stopping;
|
|
427
740
|
}
|
|
741
|
+
// ============================================================================
|
|
742
|
+
// Private Methods
|
|
743
|
+
// ============================================================================
|
|
744
|
+
/**
|
|
745
|
+
* Set up the shared event listener for job-status-changed events.
|
|
746
|
+
* This listener is shared across all waitForJob calls to avoid multiple listeners.
|
|
747
|
+
*/
|
|
428
748
|
#setupJobStatusListener() {
|
|
429
749
|
if (this.#jobStatusListenerSetup) {
|
|
430
750
|
return;
|
|
@@ -435,21 +755,26 @@ export class Client {
|
|
|
435
755
|
if (!pendingWaits || pendingWaits.size === 0) {
|
|
436
756
|
return;
|
|
437
757
|
}
|
|
758
|
+
// Fetch the job once for all pending waits
|
|
438
759
|
const job = await this.getJobById(event.jobId);
|
|
760
|
+
// Transform to JobResult
|
|
439
761
|
const result = job
|
|
440
762
|
? {
|
|
441
|
-
|
|
763
|
+
id: job.id,
|
|
442
764
|
actionName: job.actionName,
|
|
443
765
|
status: job.status,
|
|
444
766
|
groupKey: job.groupKey,
|
|
767
|
+
description: job.description,
|
|
445
768
|
input: job.input,
|
|
446
769
|
output: job.output,
|
|
447
770
|
error: job.error,
|
|
448
771
|
}
|
|
449
772
|
: null;
|
|
773
|
+
// Resolve all pending waits for this job
|
|
450
774
|
const waitsToResolve = Array.from(pendingWaits);
|
|
451
775
|
this.#pendingJobWaits.delete(event.jobId);
|
|
452
776
|
for (const wait of waitsToResolve) {
|
|
777
|
+
// Clean up timeout and abort signal
|
|
453
778
|
if (wait.timeoutId) {
|
|
454
779
|
clearTimeout(wait.timeoutId);
|
|
455
780
|
}
|
|
@@ -460,11 +785,18 @@ export class Client {
|
|
|
460
785
|
}
|
|
461
786
|
});
|
|
462
787
|
}
|
|
788
|
+
/**
|
|
789
|
+
* Remove a specific wait request from the pending waits.
|
|
790
|
+
*
|
|
791
|
+
* @param jobId - The job ID
|
|
792
|
+
* @param resolve - The resolve function to remove
|
|
793
|
+
*/
|
|
463
794
|
#removeJobWait(jobId, resolve) {
|
|
464
795
|
const pendingWaits = this.#pendingJobWaits.get(jobId);
|
|
465
796
|
if (!pendingWaits) {
|
|
466
797
|
return;
|
|
467
798
|
}
|
|
799
|
+
// Find and remove the specific wait request
|
|
468
800
|
for (const wait of pendingWaits) {
|
|
469
801
|
if (wait.resolve === resolve) {
|
|
470
802
|
if (wait.timeoutId) {
|
|
@@ -477,10 +809,16 @@ export class Client {
|
|
|
477
809
|
break;
|
|
478
810
|
}
|
|
479
811
|
}
|
|
812
|
+
// Clean up empty sets
|
|
480
813
|
if (pendingWaits.size === 0) {
|
|
481
814
|
this.#pendingJobWaits.delete(jobId);
|
|
482
815
|
}
|
|
483
816
|
}
|
|
817
|
+
/**
|
|
818
|
+
* Execute a job by finding its action and queuing it with the appropriate ActionManager.
|
|
819
|
+
*
|
|
820
|
+
* @param job - The job to execute
|
|
821
|
+
*/
|
|
484
822
|
#executeJob(job) {
|
|
485
823
|
if (!this.#actions) {
|
|
486
824
|
return;
|
|
@@ -494,22 +832,29 @@ export class Client {
|
|
|
494
832
|
});
|
|
495
833
|
return;
|
|
496
834
|
}
|
|
835
|
+
// Get or create ActionManager for this action
|
|
497
836
|
let actionManager = this.#actionManagers.get(action.name);
|
|
498
837
|
if (!actionManager) {
|
|
499
838
|
actionManager = new ActionManager({
|
|
500
839
|
action,
|
|
501
840
|
database: this.#database,
|
|
502
|
-
|
|
841
|
+
tracer: this.#tracer,
|
|
503
842
|
variables: this.#variables,
|
|
504
843
|
logger: this.#logger,
|
|
505
844
|
concurrencyLimit: this.#options.actionConcurrencyLimit,
|
|
506
845
|
});
|
|
507
846
|
this.#actionManagers.set(action.name, actionManager);
|
|
508
847
|
}
|
|
848
|
+
// Queue job execution
|
|
509
849
|
actionManager.push(job).catch((err) => {
|
|
850
|
+
// Only log unexpected errors (not cancellation/timeout which are handled elsewhere)
|
|
510
851
|
this.#logger.error({ err, jobId: job.id, actionName: action.name }, `[Duron] Error executing job ${job.id} for action ${action.name}`);
|
|
511
852
|
});
|
|
512
853
|
}
|
|
854
|
+
/**
|
|
855
|
+
* Start the pull loop for periodically fetching jobs.
|
|
856
|
+
* Only starts if not already running.
|
|
857
|
+
*/
|
|
513
858
|
#startPullLoop() {
|
|
514
859
|
if (this.#pullInterval) {
|
|
515
860
|
return;
|
|
@@ -530,8 +875,13 @@ export class Client {
|
|
|
530
875
|
this.#pullInterval = setTimeout(pull, this.#options.pullInterval);
|
|
531
876
|
}
|
|
532
877
|
};
|
|
878
|
+
// Start immediately
|
|
533
879
|
pull();
|
|
534
880
|
}
|
|
881
|
+
/**
|
|
882
|
+
* Setup the push listener for database notifications.
|
|
883
|
+
* Listens for 'job-available' events and fetches jobs when notified.
|
|
884
|
+
*/
|
|
535
885
|
#setupPushListener() {
|
|
536
886
|
this.#database.on('job-available', async () => {
|
|
537
887
|
this.fetch({
|