duron 0.2.2 → 0.3.0-beta.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 (77) hide show
  1. package/dist/action-job.d.ts +2 -0
  2. package/dist/action-job.d.ts.map +1 -1
  3. package/dist/action-job.js +20 -1
  4. package/dist/action-manager.d.ts +2 -0
  5. package/dist/action-manager.d.ts.map +1 -1
  6. package/dist/action-manager.js +3 -0
  7. package/dist/action.d.ts +7 -0
  8. package/dist/action.d.ts.map +1 -1
  9. package/dist/action.js +1 -0
  10. package/dist/adapters/adapter.d.ts +10 -2
  11. package/dist/adapters/adapter.d.ts.map +1 -1
  12. package/dist/adapters/adapter.js +59 -1
  13. package/dist/adapters/postgres/base.d.ts +9 -4
  14. package/dist/adapters/postgres/base.d.ts.map +1 -1
  15. package/dist/adapters/postgres/base.js +269 -19
  16. package/dist/adapters/postgres/schema.d.ts +249 -105
  17. package/dist/adapters/postgres/schema.d.ts.map +1 -1
  18. package/dist/adapters/postgres/schema.default.d.ts +249 -106
  19. package/dist/adapters/postgres/schema.default.d.ts.map +1 -1
  20. package/dist/adapters/postgres/schema.default.js +2 -2
  21. package/dist/adapters/postgres/schema.js +29 -1
  22. package/dist/adapters/schemas.d.ts +140 -7
  23. package/dist/adapters/schemas.d.ts.map +1 -1
  24. package/dist/adapters/schemas.js +52 -4
  25. package/dist/client.d.ts +8 -1
  26. package/dist/client.d.ts.map +1 -1
  27. package/dist/client.js +28 -0
  28. package/dist/errors.d.ts +6 -0
  29. package/dist/errors.d.ts.map +1 -1
  30. package/dist/errors.js +16 -1
  31. package/dist/index.d.ts +3 -1
  32. package/dist/index.d.ts.map +1 -1
  33. package/dist/index.js +3 -1
  34. package/dist/server.d.ts +220 -16
  35. package/dist/server.d.ts.map +1 -1
  36. package/dist/server.js +123 -8
  37. package/dist/step-manager.d.ts +8 -2
  38. package/dist/step-manager.d.ts.map +1 -1
  39. package/dist/step-manager.js +138 -15
  40. package/dist/telemetry/adapter.d.ts +85 -0
  41. package/dist/telemetry/adapter.d.ts.map +1 -0
  42. package/dist/telemetry/adapter.js +128 -0
  43. package/dist/telemetry/index.d.ts +5 -0
  44. package/dist/telemetry/index.d.ts.map +1 -0
  45. package/dist/telemetry/index.js +4 -0
  46. package/dist/telemetry/local.d.ts +21 -0
  47. package/dist/telemetry/local.d.ts.map +1 -0
  48. package/dist/telemetry/local.js +180 -0
  49. package/dist/telemetry/noop.d.ts +16 -0
  50. package/dist/telemetry/noop.d.ts.map +1 -0
  51. package/dist/telemetry/noop.js +39 -0
  52. package/dist/telemetry/opentelemetry.d.ts +24 -0
  53. package/dist/telemetry/opentelemetry.d.ts.map +1 -0
  54. package/dist/telemetry/opentelemetry.js +202 -0
  55. package/migrations/postgres/20260117231749_clumsy_penance/migration.sql +3 -0
  56. package/migrations/postgres/20260117231749_clumsy_penance/snapshot.json +988 -0
  57. package/migrations/postgres/20260118202533_wealthy_mysterio/migration.sql +24 -0
  58. package/migrations/postgres/20260118202533_wealthy_mysterio/snapshot.json +1362 -0
  59. package/package.json +6 -4
  60. package/src/action-job.ts +35 -0
  61. package/src/action-manager.ts +5 -0
  62. package/src/action.ts +56 -0
  63. package/src/adapters/adapter.ts +151 -0
  64. package/src/adapters/postgres/base.ts +342 -23
  65. package/src/adapters/postgres/schema.default.ts +2 -2
  66. package/src/adapters/postgres/schema.ts +49 -1
  67. package/src/adapters/schemas.ts +81 -5
  68. package/src/client.ts +78 -0
  69. package/src/errors.ts +45 -1
  70. package/src/index.ts +3 -1
  71. package/src/server.ts +163 -8
  72. package/src/step-manager.ts +232 -13
  73. package/src/telemetry/adapter.ts +468 -0
  74. package/src/telemetry/index.ts +17 -0
  75. package/src/telemetry/local.ts +336 -0
  76. package/src/telemetry/noop.ts +95 -0
  77. package/src/telemetry/opentelemetry.ts +310 -0
@@ -55,6 +55,8 @@ export const JobSchema = z.object({
55
55
  export const JobStepSchema = z.object({
56
56
  id: z.string(),
57
57
  jobId: z.string(),
58
+ parentStepId: z.string().nullable().default(null),
59
+ parallel: z.boolean().default(false),
58
60
  name: z.string(),
59
61
  output: z.any().nullable().default(null),
60
62
  status: StepStatusSchema,
@@ -113,8 +115,6 @@ export const GetJobsOptionsSchema = z.object({
113
115
 
114
116
  export const GetJobStepsOptionsSchema = z.object({
115
117
  jobId: z.string(),
116
- page: z.number().int().positive().optional(),
117
- pageSize: z.number().int().positive().optional(),
118
118
  search: z.string().optional(),
119
119
  updatedAfter: DateSchema.optional(),
120
120
  })
@@ -183,6 +183,13 @@ export const DeleteJobOptionsSchema = z.object({
183
183
 
184
184
  export const DeleteJobsOptionsSchema = GetJobsOptionsSchema.optional()
185
185
 
186
+ export const TimeTravelJobOptionsSchema = z.object({
187
+ /** The ID of the job to time travel */
188
+ jobId: z.string(),
189
+ /** The ID of the step to restart from */
190
+ stepId: z.string(),
191
+ })
192
+
186
193
  // ============================================================================
187
194
  // Step Option Schemas
188
195
  // ============================================================================
@@ -190,6 +197,10 @@ export const DeleteJobsOptionsSchema = GetJobsOptionsSchema.optional()
190
197
  export const CreateOrRecoverJobStepOptionsSchema = z.object({
191
198
  /** The ID of the job this step belongs to */
192
199
  jobId: z.string(),
200
+ /** The ID of the parent step (null for root steps) */
201
+ parentStepId: z.string().nullable().default(null),
202
+ /** Whether this step runs in parallel (independent from siblings during time travel) */
203
+ parallel: z.boolean().default(false),
193
204
  /** The name of the step */
194
205
  name: z.string(),
195
206
  /** Timeout in milliseconds for the step */
@@ -258,8 +269,6 @@ export const GetJobsResultSchema = z.object({
258
269
  export const GetJobStepsResultSchema = z.object({
259
270
  steps: z.array(JobStepWithoutOutputSchema),
260
271
  total: z.number().int().nonnegative(),
261
- page: z.number().int().positive(),
262
- pageSize: z.number().int().positive(),
263
272
  })
264
273
 
265
274
  export const ActionStatsSchema = z.object({
@@ -285,6 +294,63 @@ export const JobStepStatusResultSchema = z.object({
285
294
  updatedAt: DateSchema,
286
295
  })
287
296
 
297
+ // ============================================================================
298
+ // Metrics Schemas
299
+ // ============================================================================
300
+
301
+ export const MetricTypeSchema = z.enum(['metric', 'span_event', 'span_attribute'])
302
+
303
+ export const MetricSchema = z.object({
304
+ id: z.string(),
305
+ jobId: z.string(),
306
+ stepId: z.string().nullable(),
307
+ name: z.string(),
308
+ value: z.number(),
309
+ attributes: z.record(z.string(), z.any()),
310
+ type: MetricTypeSchema,
311
+ timestamp: DateSchema,
312
+ createdAt: DateSchema,
313
+ })
314
+
315
+ export const MetricSortFieldSchema = z.enum(['name', 'value', 'timestamp', 'createdAt'])
316
+
317
+ export const MetricSortSchema = z.object({
318
+ field: MetricSortFieldSchema,
319
+ order: SortOrderSchema,
320
+ })
321
+
322
+ export const MetricFiltersSchema = z.object({
323
+ name: z.union([z.string(), z.array(z.string())]).optional(),
324
+ type: z.union([MetricTypeSchema, z.array(MetricTypeSchema)]).optional(),
325
+ attributesFilter: z.record(z.string(), z.any()).optional(),
326
+ timestampRange: z.array(DateSchema).length(2).optional(),
327
+ })
328
+
329
+ export const InsertMetricOptionsSchema = z.object({
330
+ jobId: z.string(),
331
+ stepId: z.string().optional(),
332
+ name: z.string(),
333
+ value: z.number(),
334
+ attributes: z.record(z.string(), z.any()).optional(),
335
+ type: MetricTypeSchema,
336
+ })
337
+
338
+ export const GetMetricsOptionsSchema = z.object({
339
+ jobId: z.string().optional(),
340
+ stepId: z.string().optional(),
341
+ filters: MetricFiltersSchema.optional(),
342
+ sort: MetricSortSchema.optional(),
343
+ })
344
+
345
+ export const GetMetricsResultSchema = z.object({
346
+ metrics: z.array(MetricSchema),
347
+ total: z.number().int().nonnegative(),
348
+ })
349
+
350
+ export const DeleteMetricsOptionsSchema = z.object({
351
+ jobId: z.string(),
352
+ })
353
+
288
354
  // ============================================================================
289
355
  // Type Exports
290
356
  // ============================================================================
@@ -313,9 +379,19 @@ export type CancelJobOptions = z.infer<typeof CancelJobOptionsSchema>
313
379
  export type RetryJobOptions = z.infer<typeof RetryJobOptionsSchema>
314
380
  export type DeleteJobOptions = z.infer<typeof DeleteJobOptionsSchema>
315
381
  export type DeleteJobsOptions = z.infer<typeof DeleteJobsOptionsSchema>
316
- export type CreateOrRecoverJobStepOptions = z.infer<typeof CreateOrRecoverJobStepOptionsSchema>
382
+ export type CreateOrRecoverJobStepOptions = z.input<typeof CreateOrRecoverJobStepOptionsSchema>
317
383
  export type CompleteJobStepOptions = z.infer<typeof CompleteJobStepOptionsSchema>
318
384
  export type FailJobStepOptions = z.infer<typeof FailJobStepOptionsSchema>
319
385
  export type DelayJobStepOptions = z.infer<typeof DelayJobStepOptionsSchema>
320
386
  export type CancelJobStepOptions = z.infer<typeof CancelJobStepOptionsSchema>
321
387
  export type CreateOrRecoverJobStepResult = z.infer<typeof CreateOrRecoverJobStepResultSchema>
388
+ export type TimeTravelJobOptions = z.infer<typeof TimeTravelJobOptionsSchema>
389
+ export type MetricType = z.infer<typeof MetricTypeSchema>
390
+ export type Metric = z.infer<typeof MetricSchema>
391
+ export type MetricSortField = z.infer<typeof MetricSortFieldSchema>
392
+ export type MetricSort = z.infer<typeof MetricSortSchema>
393
+ export type MetricFilters = z.infer<typeof MetricFiltersSchema>
394
+ export type InsertMetricOptions = z.infer<typeof InsertMetricOptionsSchema>
395
+ export type GetMetricsOptions = z.infer<typeof GetMetricsOptionsSchema>
396
+ export type GetMetricsResult = z.infer<typeof GetMetricsResultSchema>
397
+ export type DeleteMetricsOptions = z.infer<typeof DeleteMetricsOptionsSchema>
package/src/client.ts CHANGED
@@ -11,11 +11,14 @@ import type {
11
11
  GetJobStepsResult,
12
12
  GetJobsOptions,
13
13
  GetJobsResult,
14
+ GetMetricsOptions,
15
+ GetMetricsResult,
14
16
  Job,
15
17
  JobStep,
16
18
  } from './adapters/adapter.js'
17
19
  import type { JobStatusResult, JobStepStatusResult } from './adapters/schemas.js'
18
20
  import { JOB_STATUS_CANCELLED, JOB_STATUS_COMPLETED, JOB_STATUS_FAILED, type JobStatus } from './constants.js'
21
+ import { LocalTelemetryAdapter, noopTelemetryAdapter, type TelemetryAdapter } from './telemetry/index.js'
19
22
 
20
23
  const BaseOptionsSchema = z.object({
21
24
  /**
@@ -136,6 +139,17 @@ export interface ClientOptions<
136
139
  * These can be accessed in action handlers using `ctx.var`.
137
140
  */
138
141
  variables?: TVariables
142
+
143
+ /**
144
+ * Optional telemetry adapter for observability.
145
+ * When provided, traces job and step execution with spans and allows recording custom metrics.
146
+ *
147
+ * Available adapters:
148
+ * - `openTelemetryAdapter()` - Export traces to external systems (Jaeger, OTLP, etc.)
149
+ * - `localTelemetryAdapter({ database })` - Store metrics in the Duron database
150
+ * - `noopTelemetryAdapter()` - No-op adapter (default)
151
+ */
152
+ telemetry?: TelemetryAdapter
139
153
  }
140
154
 
141
155
  interface FetchOptions {
@@ -157,6 +171,7 @@ export class Client<
157
171
  #id: string
158
172
  #actions: TActions | null
159
173
  #database: Adapter
174
+ #telemetry: TelemetryAdapter
160
175
  #variables: Record<string, unknown>
161
176
  #logger: Logger
162
177
  #started: boolean = false
@@ -190,11 +205,14 @@ export class Client<
190
205
  this.#options = BaseOptionsSchema.parse(options)
191
206
  this.#id = options.id ?? globalThis.crypto.randomUUID()
192
207
  this.#database = options.database
208
+ this.#telemetry = options.telemetry ?? noopTelemetryAdapter()
193
209
  this.#actions = options.actions ?? null
194
210
  this.#variables = options?.variables ?? {}
195
211
  this.#logger = this.#normalizeLogger(options?.logger)
196
212
  this.#database.setId(this.#id)
197
213
  this.#database.setLogger(this.#logger)
214
+ this.#telemetry.setLogger(this.#logger)
215
+ this.#telemetry.setClient(this)
198
216
  }
199
217
 
200
218
  #normalizeLogger(logger?: Logger | 'fatal' | 'error' | 'warn' | 'info' | 'debug' | 'trace' | 'silent'): Logger {
@@ -217,6 +235,28 @@ export class Client<
217
235
  return this.#logger
218
236
  }
219
237
 
238
+ /**
239
+ * Get the telemetry adapter instance.
240
+ */
241
+ get telemetry(): TelemetryAdapter {
242
+ return this.#telemetry
243
+ }
244
+
245
+ /**
246
+ * Get the database adapter instance.
247
+ */
248
+ get database(): Adapter {
249
+ return this.#database
250
+ }
251
+
252
+ /**
253
+ * Check if local telemetry is enabled.
254
+ * Returns true if using LocalTelemetryAdapter.
255
+ */
256
+ get metricsEnabled(): boolean {
257
+ return this.#telemetry instanceof LocalTelemetryAdapter
258
+ }
259
+
220
260
  /**
221
261
  * Get the current configuration of this Duron instance.
222
262
  *
@@ -361,6 +401,21 @@ export class Client<
361
401
  return this.#database.retryJob({ jobId })
362
402
  }
363
403
 
404
+ /**
405
+ * Time travel a job to restart from a specific step.
406
+ * The job must be in completed, failed, or cancelled status.
407
+ * Resets the job and ancestor steps to active status, deletes subsequent steps,
408
+ * and preserves completed parallel siblings.
409
+ *
410
+ * @param jobId - The ID of the job to time travel
411
+ * @param stepId - The ID of the step to restart from
412
+ * @returns Promise resolving to `true` if time travel succeeded, `false` otherwise
413
+ */
414
+ async timeTravelJob(jobId: string, stepId: string): Promise<boolean> {
415
+ await this.start()
416
+ return this.#database.timeTravelJob({ jobId, stepId })
417
+ }
418
+
364
419
  /**
365
420
  * Delete a job by its ID.
366
421
  * Active jobs cannot be deleted.
@@ -548,6 +603,22 @@ export class Client<
548
603
  return this.#database.getActions()
549
604
  }
550
605
 
606
+ /**
607
+ * Get metrics for a job or step.
608
+ * Only available when using LocalTelemetryAdapter.
609
+ *
610
+ * @param options - Query options including jobId/stepId, filters, sort, and pagination
611
+ * @returns Promise resolving to metrics result with pagination info
612
+ * @throws Error if not using LocalTelemetryAdapter
613
+ */
614
+ async getMetrics(options: GetMetricsOptions): Promise<GetMetricsResult> {
615
+ await this.start()
616
+ if (!this.metricsEnabled) {
617
+ throw new Error('Metrics are only available when using LocalTelemetryAdapter')
618
+ }
619
+ return this.#database.getMetrics(options)
620
+ }
621
+
551
622
  /**
552
623
  * Get action metadata including input schemas and mock data.
553
624
  * This is useful for generating UI forms or mock data.
@@ -610,6 +681,9 @@ export class Client<
610
681
  return false
611
682
  }
612
683
 
684
+ // Start telemetry adapter
685
+ await this.#telemetry.start()
686
+
613
687
  if (this.#actions) {
614
688
  if (this.#options.recoverJobsOnStart) {
615
689
  await this.#database.recoverJobs({
@@ -680,6 +754,9 @@ export class Client<
680
754
  }),
681
755
  )
682
756
 
757
+ // Stop telemetry adapter
758
+ await this.#telemetry.stop()
759
+
683
760
  const dbStopped = await this.#database.stop()
684
761
  if (!dbStopped) {
685
762
  return false
@@ -795,6 +872,7 @@ export class Client<
795
872
  actionManager = new ActionManager({
796
873
  action,
797
874
  database: this.#database,
875
+ telemetry: this.#telemetry,
798
876
  variables: this.#variables,
799
877
  logger: this.#logger,
800
878
  concurrencyLimit: this.#options.actionConcurrencyLimit,
package/src/errors.ts CHANGED
@@ -126,6 +126,38 @@ export class ActionCancelError extends DuronError {
126
126
  }
127
127
  }
128
128
 
129
+ /**
130
+ * Error thrown when a parent step completes with unhandled (non-awaited) child steps.
131
+ *
132
+ * This error indicates a bug in the action handler where child steps were started
133
+ * but not properly awaited. All child steps must be awaited before the parent returns.
134
+ */
135
+ export class UnhandledChildStepsError extends NonRetriableError {
136
+ /**
137
+ * The name of the parent step that completed with unhandled children.
138
+ */
139
+ public readonly stepName: string
140
+
141
+ /**
142
+ * The number of unhandled child steps.
143
+ */
144
+ public readonly pendingCount: number
145
+
146
+ /**
147
+ * Create a new UnhandledChildStepsError.
148
+ *
149
+ * @param stepName - The name of the parent step
150
+ * @param pendingCount - The number of unhandled child steps
151
+ */
152
+ constructor(stepName: string, pendingCount: number) {
153
+ super(
154
+ `Parent step "${stepName}" completed with ${pendingCount} unhandled child step(s). All child steps must be awaited before the parent returns.`,
155
+ )
156
+ this.stepName = stepName
157
+ this.pendingCount = pendingCount
158
+ }
159
+ }
160
+
129
161
  /**
130
162
  * Checks if an error is a DuronError instance.
131
163
  */
@@ -137,7 +169,19 @@ export function isDuronError(error: unknown): error is DuronError {
137
169
  * Checks if an error is a NonRetriableError instance.
138
170
  */
139
171
  export function isNonRetriableError(error: unknown): error is NonRetriableError {
140
- return error instanceof NonRetriableError || error instanceof ActionCancelError || error instanceof ActionTimeoutError
172
+ return (
173
+ error instanceof NonRetriableError ||
174
+ error instanceof ActionCancelError ||
175
+ error instanceof ActionTimeoutError ||
176
+ error instanceof UnhandledChildStepsError
177
+ )
178
+ }
179
+
180
+ /**
181
+ * Checks if an error is an UnhandledChildStepsError instance.
182
+ */
183
+ export function isUnhandledChildStepsError(error: unknown): error is UnhandledChildStepsError {
184
+ return error instanceof UnhandledChildStepsError
141
185
  }
142
186
 
143
187
  /**
package/src/index.ts CHANGED
@@ -2,9 +2,11 @@ import type { Action } from './action.js'
2
2
  import { Client, type ClientOptions } from './client.js'
3
3
 
4
4
  export { defineAction } from './action.js'
5
+ export * from './client.js'
5
6
  export * from './constants.js'
6
- export { NonRetriableError } from './errors.js'
7
+ export { NonRetriableError, UnhandledChildStepsError } from './errors.js'
7
8
  export * from './server.js'
9
+ export * from './telemetry/index.js'
8
10
 
9
11
  export const duron = <
10
12
  TActions extends Record<string, Action<any, any, TVariables>>,
package/src/server.ts CHANGED
@@ -2,17 +2,20 @@ import { Elysia } from 'elysia'
2
2
  import { jwtVerify, SignJWT } from 'jose'
3
3
  import { z } from 'zod'
4
4
 
5
- import type { GetJobStepsOptions, GetJobsOptions } from './adapters/adapter.js'
5
+ import type { GetJobStepsOptions, GetJobsOptions, GetMetricsOptions } from './adapters/adapter.js'
6
6
  import {
7
7
  GetActionsResultSchema,
8
8
  GetJobStepsResultSchema,
9
9
  GetJobsResultSchema,
10
+ GetMetricsResultSchema,
10
11
  JobSchema,
11
12
  JobSortFieldSchema,
12
13
  JobStatusResultSchema,
13
14
  JobStatusSchema,
14
15
  JobStepSchema,
15
16
  JobStepStatusResultSchema,
17
+ MetricSortFieldSchema,
18
+ MetricTypeSchema,
16
19
  SortOrderSchema,
17
20
  } from './adapters/schemas.js'
18
21
  import type { Client } from './client.js'
@@ -61,14 +64,10 @@ export class UnauthorizedError extends Error {
61
64
 
62
65
  export const GetJobStepsQuerySchema = z
63
66
  .object({
64
- page: z.coerce.number().int().min(1).optional(),
65
- pageSize: z.coerce.number().int().min(1).max(1000).optional(),
66
67
  search: z.string().optional(),
67
68
  fUpdatedAfter: z.coerce.date().optional(),
68
69
  })
69
70
  .transform((data) => ({
70
- page: data.page,
71
- pageSize: data.pageSize,
72
71
  search: data.search,
73
72
  updatedAfter: data.fUpdatedAfter,
74
73
  }))
@@ -177,6 +176,50 @@ export const GetActionsMetadataResponseSchema = z.array(
177
176
  export type GetJobsQueryInput = z.input<typeof GetJobsQuerySchema>
178
177
  export type GetJobStepsQueryInput = z.input<typeof GetJobStepsQuerySchema>
179
178
 
179
+ // Metrics query schema
180
+ export const GetMetricsQuerySchema = z
181
+ .object({
182
+ // Filters
183
+ fName: z.union([z.string(), z.array(z.string())]).optional(),
184
+ fType: z.union([MetricTypeSchema, z.array(MetricTypeSchema)]).optional(),
185
+ fTimestampRange: z.array(z.coerce.date()).length(2).optional(),
186
+ fAttributesFilter: z.record(z.string(), z.any()).optional(),
187
+
188
+ // Sort - format: "field:asc" or "field:desc"
189
+ sort: z.string().optional(),
190
+ })
191
+ .transform((data) => {
192
+ const filters: any = {}
193
+
194
+ if (data.fName) filters.name = data.fName
195
+ if (data.fType) filters.type = data.fType
196
+ if (data.fTimestampRange) filters.timestampRange = data.fTimestampRange
197
+ if (data.fAttributesFilter) filters.attributesFilter = data.fAttributesFilter
198
+
199
+ // Parse sort string: "field:asc" -> { field: 'field', order: 'asc' }
200
+ let sort: { field: z.infer<typeof MetricSortFieldSchema>; order: z.infer<typeof SortOrderSchema> } | undefined
201
+ if (data.sort) {
202
+ const [field, order] = data.sort.split(':').map((s) => s.trim())
203
+ if (field && order) {
204
+ const fieldResult = MetricSortFieldSchema.safeParse(field)
205
+ const orderResult = SortOrderSchema.safeParse(order.toLowerCase())
206
+ if (fieldResult.success && orderResult.success) {
207
+ sort = {
208
+ field: fieldResult.data,
209
+ order: orderResult.data,
210
+ }
211
+ }
212
+ }
213
+ }
214
+
215
+ return {
216
+ filters: Object.keys(filters).length > 0 ? filters : undefined,
217
+ sort,
218
+ }
219
+ })
220
+
221
+ export type GetMetricsQueryInput = z.input<typeof GetMetricsQuerySchema>
222
+
180
223
  export const ErrorResponseSchema = z.object({
181
224
  error: z.string(),
182
225
  message: z.string().optional(),
@@ -201,6 +244,15 @@ export const RetryJobResponseSchema = z.object({
201
244
  newJobId: z.string(),
202
245
  })
203
246
 
247
+ export const TimeTravelJobBodySchema = z.object({
248
+ stepId: z.uuid(),
249
+ })
250
+
251
+ export const TimeTravelJobResponseSchema = z.object({
252
+ success: z.boolean(),
253
+ message: z.string(),
254
+ })
255
+
204
256
  // ============================================================================
205
257
  // Server Factory
206
258
  // ============================================================================
@@ -216,6 +268,14 @@ export interface CreateServerOptions<P extends string> {
216
268
  */
217
269
  prefix?: P
218
270
 
271
+ /**
272
+ * Enable metrics endpoints (/jobs/:id/metrics, /steps/:id/metrics).
273
+ * Only works when client is configured with LocalTelemetryAdapter.
274
+ * When true, enables the dashboard to show metrics buttons.
275
+ * @default auto-detected from client.metricsEnabled
276
+ */
277
+ metricsEnabled?: boolean
278
+
219
279
  login?: {
220
280
  onLogin: (body: { email: string; password: string }) => Promise<boolean>
221
281
  jwtSecret: string | Uint8Array
@@ -237,12 +297,15 @@ export interface CreateServerOptions<P extends string> {
237
297
  * @param options - Configuration options
238
298
  * @returns Elysia server instance
239
299
  */
240
- export function createServer<P extends string>({ client, prefix, login }: CreateServerOptions<P>) {
300
+ export function createServer<P extends string>({ client, prefix, login, metricsEnabled }: CreateServerOptions<P>) {
241
301
  // Convert string secret to Uint8Array if needed
242
302
  const secretKey = typeof login?.jwtSecret === 'string' ? new TextEncoder().encode(login?.jwtSecret) : login?.jwtSecret
243
303
 
244
304
  const routePrefix = (prefix ?? '/api') as P
245
305
 
306
+ // Auto-detect metricsEnabled from client if not explicitly set
307
+ const isMetricsEnabled = metricsEnabled ?? client.metricsEnabled
308
+
246
309
  return new Elysia({
247
310
  prefix: routePrefix,
248
311
  })
@@ -345,8 +408,6 @@ export function createServer<P extends string>({ client, prefix, login }: Create
345
408
  async ({ params, query }) => {
346
409
  const options: GetJobStepsOptions = {
347
410
  jobId: params.id,
348
- page: query.page,
349
- pageSize: query.pageSize,
350
411
  search: query.search,
351
412
  updatedAfter: query.updatedAfter,
352
413
  }
@@ -490,6 +551,32 @@ export function createServer<P extends string>({ client, prefix, login }: Create
490
551
  auth: true,
491
552
  },
492
553
  )
554
+ .post(
555
+ '/jobs/:id/time-travel',
556
+ async ({ params, body }) => {
557
+ const success = await client.timeTravelJob(params.id, body.stepId)
558
+ if (!success) {
559
+ throw new Error(
560
+ `Could not time travel job ${params.id}. The job may not be in a terminal state or the step may not exist.`,
561
+ )
562
+ }
563
+ return {
564
+ success: true,
565
+ message: `Job ${params.id} has been time traveled to step ${body.stepId}`,
566
+ }
567
+ },
568
+ {
569
+ params: JobIdParamsSchema,
570
+ body: TimeTravelJobBodySchema,
571
+ response: {
572
+ 200: TimeTravelJobResponseSchema,
573
+ 400: ErrorResponseSchema,
574
+ 500: ErrorResponseSchema,
575
+ 401: ErrorResponseSchema,
576
+ },
577
+ auth: true,
578
+ },
579
+ )
493
580
  .delete(
494
581
  '/jobs/:id',
495
582
  async ({ params }) => {
@@ -579,6 +666,74 @@ export function createServer<P extends string>({ client, prefix, login }: Create
579
666
  auth: true,
580
667
  },
581
668
  )
669
+ .get(
670
+ '/config',
671
+ async () => {
672
+ return {
673
+ metricsEnabled: isMetricsEnabled,
674
+ authEnabled: !!login,
675
+ }
676
+ },
677
+ {
678
+ response: {
679
+ 200: z.object({
680
+ metricsEnabled: z.boolean(),
681
+ authEnabled: z.boolean(),
682
+ }),
683
+ 500: ErrorResponseSchema,
684
+ },
685
+ },
686
+ )
687
+ .get(
688
+ '/jobs/:id/metrics',
689
+ async ({ params, query }) => {
690
+ if (!isMetricsEnabled) {
691
+ throw new Error('Metrics are not enabled. Use LocalTelemetryAdapter to enable metrics.')
692
+ }
693
+ const options: GetMetricsOptions = {
694
+ jobId: params.id,
695
+ filters: query.filters,
696
+ sort: query.sort,
697
+ }
698
+ return client.getMetrics(options)
699
+ },
700
+ {
701
+ params: JobIdParamsSchema,
702
+ query: GetMetricsQuerySchema,
703
+ response: {
704
+ 200: GetMetricsResultSchema,
705
+ 400: ErrorResponseSchema,
706
+ 500: ErrorResponseSchema,
707
+ 401: ErrorResponseSchema,
708
+ },
709
+ auth: true,
710
+ },
711
+ )
712
+ .get(
713
+ '/steps/:id/metrics',
714
+ async ({ params, query }) => {
715
+ if (!isMetricsEnabled) {
716
+ throw new Error('Metrics are not enabled. Use LocalTelemetryAdapter to enable metrics.')
717
+ }
718
+ const options: GetMetricsOptions = {
719
+ stepId: params.id,
720
+ filters: query.filters,
721
+ sort: query.sort,
722
+ }
723
+ return client.getMetrics(options)
724
+ },
725
+ {
726
+ params: StepIdParamsSchema,
727
+ query: GetMetricsQuerySchema,
728
+ response: {
729
+ 200: GetMetricsResultSchema,
730
+ 400: ErrorResponseSchema,
731
+ 500: ErrorResponseSchema,
732
+ 401: ErrorResponseSchema,
733
+ },
734
+ auth: true,
735
+ },
736
+ )
582
737
  .post(
583
738
  '/actions/:actionName/run',
584
739
  async ({ params, body }) => {