@ultimat3/jobs 29.4.0 → 30.0.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 (109) hide show
  1. package/CLAUDE.md +68 -89
  2. package/README.md +304 -10
  3. package/package.json +18 -6
  4. package/src/backfill-definition.ts +184 -0
  5. package/src/backfill-errors.ts +18 -2
  6. package/src/backfill-pass.ts +43 -40
  7. package/src/backfill-pending.ts +40 -17
  8. package/src/backfill-reader.ts +94 -0
  9. package/src/backfill-registry.ts +5 -0
  10. package/src/backfill-scope.ts +1 -2
  11. package/src/backfill-source.ts +113 -0
  12. package/src/backfill.ts +54 -135
  13. package/src/consumer-errors.ts +42 -0
  14. package/src/consumer-guard.ts +19 -0
  15. package/src/consumer-loop.ts +171 -0
  16. package/src/consumer-registry.ts +103 -0
  17. package/src/consumer-types.ts +81 -0
  18. package/src/consumer.ts +147 -0
  19. package/src/driver-memory-operator.ts +25 -2
  20. package/src/driver-memory.ts +35 -1
  21. package/src/driver-pg-claim-sql.ts +128 -0
  22. package/src/driver-pg-ddl.ts +11 -2
  23. package/src/driver-pg-demand-sql.ts +27 -0
  24. package/src/driver-pg-enqueue-many.ts +153 -0
  25. package/src/driver-pg-event-wake-sql.ts +51 -0
  26. package/src/driver-pg-operator-sql.ts +34 -6
  27. package/src/driver-pg-operator.ts +36 -2
  28. package/src/driver-pg-outbox-sql.ts +34 -0
  29. package/src/driver-pg-sql.ts +24 -91
  30. package/src/driver-pg.ts +17 -1
  31. package/src/driver-redis-refusal.ts +25 -0
  32. package/src/driver-redis-scripts.ts +79 -30
  33. package/src/driver-redis.ts +70 -42
  34. package/src/driver.ts +38 -1
  35. package/src/enqueue-record.ts +137 -0
  36. package/src/error-codes.ts +233 -0
  37. package/src/errors-concurrency.ts +2 -2
  38. package/src/errors-declaration.ts +15 -2
  39. package/src/errors-operator.ts +1 -1
  40. package/src/errors-requeue.ts +1 -1
  41. package/src/errors-saga.ts +87 -0
  42. package/src/errors-steps.ts +35 -0
  43. package/src/errors-tenant.ts +1 -1
  44. package/src/errors.ts +25 -165
  45. package/src/events-pg.ts +20 -8
  46. package/src/events.ts +14 -3
  47. package/src/execute-ctx.ts +51 -0
  48. package/src/execute-unwind.ts +191 -0
  49. package/src/execute.ts +50 -51
  50. package/src/export-errors.ts +1 -1
  51. package/src/failure-row.ts +43 -0
  52. package/src/import-errors.ts +102 -0
  53. package/src/import-format.ts +232 -0
  54. package/src/import-pass.ts +255 -0
  55. package/src/import-source.ts +49 -0
  56. package/src/import.ts +175 -0
  57. package/src/index.ts +163 -7
  58. package/src/inspect-operator.ts +24 -3
  59. package/src/inspect.ts +17 -4
  60. package/src/introspection.ts +24 -1
  61. package/src/job-enqueue-many.ts +108 -0
  62. package/src/job-types.ts +229 -0
  63. package/src/job.ts +19 -208
  64. package/src/metrics.ts +10 -0
  65. package/src/outbox-pg.ts +35 -0
  66. package/src/outbox-relay.ts +58 -19
  67. package/src/outbox.ts +65 -73
  68. package/src/purge-entities.ts +92 -0
  69. package/src/purge.ts +10 -2
  70. package/src/queue-wake.ts +1 -1
  71. package/src/refresh-view.ts +76 -0
  72. package/src/retry-classification.ts +27 -8
  73. package/src/retry.ts +26 -0
  74. package/src/rollup-errors.ts +27 -0
  75. package/src/rollup.ts +190 -0
  76. package/src/run-deadline.ts +11 -7
  77. package/src/scheduler.ts +2 -1
  78. package/src/settled.ts +56 -15
  79. package/src/step-resolve.ts +37 -0
  80. package/src/steps-api.ts +111 -0
  81. package/src/steps-core.ts +26 -0
  82. package/src/steps-output.ts +142 -0
  83. package/src/steps-store.ts +68 -0
  84. package/src/steps-suspension.ts +51 -0
  85. package/src/steps-timeout.ts +10 -5
  86. package/src/steps-unwind.ts +94 -0
  87. package/src/steps-wait.ts +163 -0
  88. package/src/steps.ts +174 -210
  89. package/src/subject-erase-pass.ts +134 -0
  90. package/src/subject-errors.ts +48 -0
  91. package/src/subject-export-pass.ts +228 -0
  92. package/src/subject-jobs.ts +121 -0
  93. package/src/subject-plan.ts +287 -0
  94. package/src/subject-select.ts +106 -0
  95. package/src/subject.ts +119 -0
  96. package/src/tenant.ts +69 -1
  97. package/src/type-pins.ts +90 -0
  98. package/src/webhook-errors.ts +1 -1
  99. package/src/webhook-receiver-errors.ts +59 -0
  100. package/src/webhook-receiver-route.ts +127 -0
  101. package/src/webhook-receiver-types.ts +97 -0
  102. package/src/webhook-receiver.ts +132 -0
  103. package/src/webhook-target.ts +20 -43
  104. package/src/webhook.ts +7 -4
  105. package/src/worker-queue-demand.ts +57 -0
  106. package/src/worker-queue-depth.ts +15 -3
  107. package/src/worker-registry.ts +4 -1
  108. package/src/worker-types.ts +2 -0
  109. package/src/worker.ts +2 -2
@@ -0,0 +1,233 @@
1
+ // The X_* codes owned by @ultimat3/jobs, and nothing else: the two lists, their titles, the one
2
+ // registration call and the retry classification. `bun run new-error-code --package jobs` writes
3
+ // here. The classes that throw these codes live in `./errors`, which imports this file for its
4
+ // registration, so a module that can construct one of them has the code registered.
5
+ import { registerErrorCodes, registerErrorRetry } from '@ultimat3/core';
6
+
7
+ /** Codes this package declares and owns. */
8
+ export const JOB_OWNED_ERROR_CODES = [
9
+ 'X_JOB_DUPLICATE',
10
+ 'X_STEP_DUPLICATE',
11
+ 'X_JOB_TIMEOUT',
12
+ 'X_JOB_MAX_ATTEMPTS',
13
+ 'X_DRIVER_UNAVAILABLE',
14
+ 'X_IDEMPOTENCY_REQUIRED',
15
+ 'X_JOB_TENANT_REQUIRED',
16
+ 'X_JOB_CONCURRENCY_UNENFORCEABLE',
17
+ 'X_JOB_LEASE_LOST',
18
+ 'X_JOB_SLOT_LOST',
19
+ 'X_JOB_NOT_CANCELLABLE',
20
+ 'X_OUTBOX_NO_TX',
21
+ 'X_BACKFILL_PENDING',
22
+ 'X_BACKFILL_APPLIED',
23
+ 'X_BACKFILL_ENVIRONMENT',
24
+ 'X_BACKFILL_MIGRATION_PENDING',
25
+ 'X_BACKFILL_RUNNING',
26
+ 'X_BACKFILL_STALLED',
27
+ 'X_BACKFILL_UNKNOWN',
28
+ 'X_JOB_ROW_STATUS_UNKNOWN',
29
+ 'X_ACTION_JOB_UNBRIDGED',
30
+ 'X_JOB_CLAIM_QUEUES_EMPTY',
31
+ 'X_WEBHOOK_ENDPOINT_UNKNOWN',
32
+ 'X_WEBHOOK_ENDPOINT_INVALID',
33
+ 'X_WEBHOOK_ENDPOINT_DISABLED',
34
+ 'X_WEBHOOK_EVENT_UNKNOWN',
35
+ 'X_WEBHOOK_EVENT_INVALID',
36
+ 'X_WEBHOOK_DELIVERY_FAILED',
37
+ 'X_WEBHOOK_DELIVERY_THROTTLED',
38
+ 'X_WEBHOOK_DELIVERY_REJECTED',
39
+ 'X_EXPORT_ROW_INVALID',
40
+ 'X_EXPORT_PART_TOO_LARGE',
41
+ 'X_JOB_DECLARATION_INVALID',
42
+ 'X_JOB_NOT_REQUEUEABLE',
43
+ 'X_JOB_KEY_BUSY',
44
+ 'X_JOB_NOT_REMOVABLE',
45
+ 'X_JOB_NOT_PROMOTABLE',
46
+ 'X_JOB_PAGE_INVALID',
47
+ 'X_JOB_ON_SETTLED_FAILED',
48
+ 'X_JOB_NOT_FOUND',
49
+ 'X_JOB_TENANT_MISMATCH',
50
+ 'X_STEP_OUTPUT_UNSERIALIZABLE',
51
+ 'X_JOB_EVENT_INVALID',
52
+ 'X_IMPORT_HEADER_MISMATCH',
53
+ 'X_IMPORT_RECORD_TOO_LARGE',
54
+ 'X_IMPORT_TOO_MANY_ERRORS',
55
+ 'X_JOB_ENQUEUE_BATCH_TOO_LARGE',
56
+ 'X_WEBHOOK_RECEIVER_INVALID',
57
+ 'X_WEBHOOK_RECEIVER_UNAVAILABLE',
58
+ 'X_WEBHOOK_EVENT_ID_INVALID',
59
+ 'X_JOB_COMPENSATION_FAILED',
60
+ 'X_STEP_OUTCOME_UNKNOWN',
61
+ 'X_CONSUMER_INVALID',
62
+ 'X_CONSUMER_SELF_ENQUEUE',
63
+ 'X_BACKFILL_SOURCE_INVALID',
64
+ 'X_STEP_NOT_RESOLVABLE',
65
+ 'X_JOB_ROLLUP_INVALID',
66
+ 'X_JOB_REFRESH_VIEW_INVALID',
67
+ 'X_SUBJECT_TABLE_UNDECLARED',
68
+ 'X_SUBJECT_TREATMENT_INVALID',
69
+ 'X_SUBJECT_CYCLE',
70
+ ] as const;
71
+
72
+ /**
73
+ * `X_NOT_IMPLEMENTED`, `X_ABORTED` and `X_DRAINING` are `@ultimat3/core`'s. The first is thrown as
74
+ * core's own `NotImplementedError` (`inspect.ts`), the other two by
75
+ * `JobAbortedError` and `JobDrainedError` below; jobs keeps no title for any of the three, because the copy this file used to hold was a second title that nothing would have failed
76
+ * on once core's changed. Listed here all the same, so `JobErrorCode` can name every code a job
77
+ * can see — `X_DRAINING` was thrown for a day before it was, and the type said it could not be.
78
+ */
79
+ export const JOB_BORROWED_ERROR_CODES = ['X_NOT_IMPLEMENTED', 'X_ABORTED', 'X_DRAINING'] as const;
80
+
81
+ /** Every code jobs can throw: the ones it owns plus the ones it borrows. */
82
+ export const JOB_ERROR_CODES = [...JOB_OWNED_ERROR_CODES, ...JOB_BORROWED_ERROR_CODES] as const;
83
+
84
+ export type JobOwnedErrorCode = (typeof JOB_OWNED_ERROR_CODES)[number];
85
+ export type JobErrorCode = (typeof JOB_ERROR_CODES)[number];
86
+
87
+ export const JOB_ERROR_TITLES: Readonly<Record<JobOwnedErrorCode, string>> = {
88
+ X_JOB_DUPLICATE: 'a live key already has a job',
89
+ X_STEP_DUPLICATE: 'two step.run calls share a name',
90
+ X_JOB_TIMEOUT: 'a job exceeded its wall-clock limit',
91
+ X_JOB_MAX_ATTEMPTS: 'the job exhausted its retries',
92
+ X_DRIVER_UNAVAILABLE: 'the queue driver is unreachable',
93
+ X_IDEMPOTENCY_REQUIRED: 'the job has no idempotencyKey',
94
+ X_JOB_TENANT_REQUIRED: 'the job declares no tenant',
95
+ X_JOB_CONCURRENCY_UNENFORCEABLE: 'job.concurrency is declared and cannot be enforced',
96
+ X_JOB_LEASE_LOST: 'the queue took this job back mid-run',
97
+ X_JOB_SLOT_LOST: 'the fleet concurrency slot was taken by another worker',
98
+ X_JOB_NOT_CANCELLABLE: 'the job cannot be cancelled',
99
+ X_OUTBOX_NO_TX: 'enqueue outside a transaction',
100
+ X_BACKFILL_PENDING: 'a declared backfill has never completed',
101
+ X_BACKFILL_APPLIED: 'the ledger already holds a completed pass',
102
+ X_BACKFILL_ENVIRONMENT: 'the backfill is not declared for this environment',
103
+ X_BACKFILL_MIGRATION_PENDING: 'the migration this backfill requires is not applied',
104
+ X_BACKFILL_RUNNING: 'a pass under this name is already live',
105
+ X_BACKFILL_STALLED: 'the sweep ended with rows its own count still matches',
106
+ X_BACKFILL_UNKNOWN: 'no declaration carries this backfill name',
107
+ X_JOB_ROW_STATUS_UNKNOWN: 'a queue row carries a status this build does not know',
108
+ X_ACTION_JOB_UNBRIDGED: 'an action projection was registered as a job',
109
+ X_JOB_CLAIM_QUEUES_EMPTY: 'a claim named no queue, and the drivers do not agree what that means',
110
+ X_WEBHOOK_ENDPOINT_UNKNOWN: 'no endpoint carries this id',
111
+ X_WEBHOOK_ENDPOINT_INVALID: 'the endpoint cannot be delivered to as declared',
112
+ X_WEBHOOK_ENDPOINT_DISABLED: 'the endpoint takes no deliveries',
113
+ X_WEBHOOK_EVENT_UNKNOWN: 'no event carries this id',
114
+ X_WEBHOOK_EVENT_INVALID: 'the event cannot be signed as declared',
115
+ X_WEBHOOK_DELIVERY_FAILED: 'the endpoint did not accept the delivery',
116
+ X_WEBHOOK_DELIVERY_THROTTLED: 'the endpoint asked for the delivery later, and said when',
117
+ X_WEBHOOK_DELIVERY_REJECTED: 'the endpoint refused the delivery in a way a retry cannot change',
118
+ X_EXPORT_ROW_INVALID: 'row() answered something the declared columns do not carry',
119
+ X_EXPORT_PART_TOO_LARGE: 'one page encoded to more bytes than a part may hold',
120
+ X_JOB_DECLARATION_INVALID: 'the job declaration is missing or mis-declares a field',
121
+ X_JOB_NOT_REQUEUEABLE: 'the job is still live and cannot be requeued',
122
+ X_JOB_KEY_BUSY: 'the concurrency key already holds its limit of runs',
123
+ X_JOB_NOT_REMOVABLE: 'the job is running and cannot be removed',
124
+ X_JOB_NOT_PROMOTABLE: 'the job is not waiting on its run time',
125
+ X_JOB_PAGE_INVALID: 'a job list page was asked for outside its bounds',
126
+ X_JOB_ON_SETTLED_FAILED: 'the onSettled hook failed after the run was settled',
127
+ X_JOB_NOT_FOUND: 'the queue holds no job with this id',
128
+ X_JOB_TENANT_MISMATCH: "a job's payload names an org other than the tenant it runs under",
129
+ X_STEP_OUTPUT_UNSERIALIZABLE: 'a step returned a value JSON cannot hold',
130
+ X_JOB_EVENT_INVALID: 'a waited event failed its schema',
131
+ X_IMPORT_HEADER_MISMATCH: 'the csv header does not name the declared columns',
132
+ X_IMPORT_RECORD_TOO_LARGE: 'one record runs past the bytes a record may hold',
133
+ X_IMPORT_TOO_MANY_ERRORS: 'more rows were rejected than the import allows',
134
+ X_JOB_ENQUEUE_BATCH_TOO_LARGE: 'enqueueMany was handed more inputs than one call may stage',
135
+ X_WEBHOOK_RECEIVER_INVALID: 'a webhookReceiver() declaration cannot be served',
136
+ X_WEBHOOK_RECEIVER_UNAVAILABLE:
137
+ 'an inbound webhook could not be queued, so it was not acknowledged',
138
+ X_WEBHOOK_EVENT_ID_INVALID:
139
+ 'the event id a webhook receiver verified is not one it can key a job by',
140
+ X_JOB_COMPENSATION_FAILED: 'a compensator could not undo its step',
141
+ X_STEP_OUTCOME_UNKNOWN: 'a once step started and never completed',
142
+ X_CONSUMER_INVALID: 'a consumer() declaration cannot be run as declared',
143
+ X_CONSUMER_SELF_ENQUEUE: 'a consumer enqueued itself from inside its own run',
144
+ X_BACKFILL_SOURCE_INVALID:
145
+ 'a backfill() declares both or neither of source and pages, or input with no scope',
146
+ X_STEP_NOT_RESOLVABLE: 'the step is not an unknown once outcome an operator can resolve',
147
+ X_JOB_ROLLUP_INVALID: 'a rollup declaration cannot be run',
148
+ X_JOB_REFRESH_VIEW_INVALID: 'a refreshView declaration names no materialized view it can refresh',
149
+ X_SUBJECT_TABLE_UNDECLARED:
150
+ 'a table reaches the declared subject and defineSubject gives it no treatment',
151
+ X_SUBJECT_TREATMENT_INVALID: 'a defineSubject treatment cannot be carried out',
152
+ X_SUBJECT_CYCLE: 'the subject erase has no deletion order',
153
+ };
154
+
155
+ // One unconditional call, so a second package claiming one of jobs' codes throws
156
+ // X_ERROR_CODE_DUPLICATE instead of losing silently to whichever module imported first.
157
+ registerErrorCodes(
158
+ Object.fromEntries(Object.entries(JOB_ERROR_TITLES).map(([code, title]) => [code, { title }])),
159
+ );
160
+
161
+ /**
162
+ * The codes of this package's that can be thrown INSIDE a job body, classified — `executeJob`
163
+ * reads this, so a `terminal` one dead-letters on the attempt it happened instead of spending the
164
+ * whole policy on an answer that cannot change. Same rule every package uses: retryable means the
165
+ * same code, run again, has a real chance of a different answer.
166
+ *
167
+ * Two are deliberately absent. `X_JOB_LEASE_LOST` and `X_JOB_SLOT_LOST` mean the row is somebody
168
+ * else's now, so this attempt's verdict is not this attempt's to give: dead-lettering would settle
169
+ * a job another worker is running. They keep the attempt-count path, which ends in the queue
170
+ * re-delivering — the honest outcome for "we stopped owning it".
171
+ */
172
+ registerErrorRetry({
173
+ X_JOB_TIMEOUT: 'retryable',
174
+ X_DRIVER_UNAVAILABLE: 'retryable',
175
+ // A second `step.run` under one name is a defect in the handler, replayed identically forever.
176
+ X_STEP_DUPLICATE: 'terminal',
177
+ // A sweep whose source ran dry while its own count still matches rows: the next attempt resumes
178
+ // at the cursor that just ran dry and diverges again.
179
+ X_BACKFILL_STALLED: 'terminal',
180
+ X_BACKFILL_ENVIRONMENT: 'terminal',
181
+ X_BACKFILL_APPLIED: 'terminal',
182
+ // The one retryable webhook code, and the reason the split exists at all: a 5xx, a 429 or a
183
+ // connection that never opened is the same request landing later, which is what the job's retry
184
+ // policy is for.
185
+ X_WEBHOOK_DELIVERY_FAILED: 'retryable',
186
+ // `retry-after` and not `retryable`: the receiver NAMED a delay, so `statedDelayMs` reads
187
+ // `meta.retryAfterSeconds` and the nack waits that long (clamped by the policy's `maxDelay`)
188
+ // instead of guessing a curve against an answer it was already given.
189
+ X_WEBHOOK_DELIVERY_THROTTLED: 'retry-after',
190
+ // The rest are `terminal` and every one is LISTED rather than left to the default, because
191
+ // `classifyThrown` reads an unregistered code carrying `terminal` as UNCLASSIFIED — which spends
192
+ // a delivery's whole retry policy re-proving that a 404 endpoint is still a 404.
193
+ X_WEBHOOK_DELIVERY_REJECTED: 'terminal',
194
+ X_WEBHOOK_ENDPOINT_UNKNOWN: 'terminal',
195
+ X_WEBHOOK_ENDPOINT_INVALID: 'terminal',
196
+ X_WEBHOOK_ENDPOINT_DISABLED: 'terminal',
197
+ X_WEBHOOK_EVENT_UNKNOWN: 'terminal',
198
+ X_WEBHOOK_EVENT_INVALID: 'terminal',
199
+ // Both export codes are refusals of the DECLARATION and not of the data: the same `row()` over
200
+ // the same page answers the same way on every attempt, so an unclassified reading would spend
201
+ // the whole retry policy proving it.
202
+ X_EXPORT_ROW_INVALID: 'terminal',
203
+ X_EXPORT_PART_TOO_LARGE: 'terminal',
204
+ // An import reads the same bytes on every attempt: a header, an unclosed quote and a count of
205
+ // refused rows answer identically, so the repair is the file and a new enqueue, never a retry.
206
+ X_IMPORT_HEADER_MISMATCH: 'terminal',
207
+ X_IMPORT_RECORD_TOO_LARGE: 'terminal',
208
+ X_IMPORT_TOO_MANY_ERRORS: 'terminal',
209
+ // The worker settles a busy key itself; listed so a body that RETHROWS one it was handed stops.
210
+ X_JOB_KEY_BUSY: 'terminal',
211
+ // The payload names the wrong org, or none its tenant can run under — a fact about the ROW, so
212
+ // every attempt and every `x jobs retry` is refused identically: the repair is a new enqueue.
213
+ X_JOB_TENANT_MISMATCH: 'terminal',
214
+ // The same value is refused on attempt five; the step's side effect has already happened.
215
+ X_STEP_OUTPUT_UNSERIALIZABLE: 'terminal',
216
+ // The stored event is the same on every attempt: the repair is the publisher or the schema.
217
+ X_JOB_EVENT_INVALID: 'terminal',
218
+ // The same list is the same length on every attempt: the repair is the caller's slicing.
219
+ X_JOB_ENQUEUE_BATCH_TOO_LARGE: 'terminal',
220
+ // A step name the framework reserves, or `compensate` on a job without `compensates: true`: the
221
+ // body is the same on every attempt, so the repair is the declaration.
222
+ X_JOB_DECLARATION_INVALID: 'terminal',
223
+ // Only a person or the vendor can say whether a `once` step ran (`x jobs resolve-step`).
224
+ X_STEP_OUTCOME_UNKNOWN: 'terminal',
225
+ // The unwind's own attempts are spent, or it met an unknown `once` step: the repair is a person.
226
+ X_JOB_COMPENSATION_FAILED: 'terminal',
227
+ // The body that enqueued itself does so on every attempt: the repair is deleting the call.
228
+ X_CONSUMER_SELF_ENQUEUE: 'terminal',
229
+ // A subject's plan is read off the declarations: the same entities plan the same way every time.
230
+ X_SUBJECT_TABLE_UNDECLARED: 'terminal',
231
+ X_SUBJECT_TREATMENT_INVALID: 'terminal',
232
+ X_SUBJECT_CYCLE: 'terminal',
233
+ });
@@ -1,7 +1,7 @@
1
1
  // Single responsibility: the refusals `job.concurrency` earns — a cap mis-declared, a key that
2
2
  // cannot name a lease, a key already holding its limit, and a driver that cannot hold the cap.
3
- // Its own module because `errors.ts` sits at the size ceiling; the CODES are registered there,
4
- // beside every other jobs code.
3
+ // Its own module because `errors.ts` sits at the size ceiling; the CODES are registered in
4
+ // `error-codes.ts`, beside every other jobs code.
5
5
 
6
6
  import { isFixShellSafe, renderCauseValue, renderFixShellArg, UltimateError } from '@ultimat3/core';
7
7
 
@@ -1,6 +1,7 @@
1
1
  // Single responsibility: the refusal a `job()` declaration earns when it lacks a field every job
2
- // declares. Its own module because `errors.ts` sits at the size ceiling; the CODE is registered
3
- // there, beside every other jobs code, and only the constructor lives here.
2
+ // declares. Its own module because `errors.ts` sits at the size ceiling; the CODE is registered in
3
+ // `error-codes.ts`, beside every other jobs code; the constructor and the read that finds the
4
+ // missing fields live here.
4
5
 
5
6
  import { UltimateError } from '@ultimat3/core';
6
7
 
@@ -32,3 +33,15 @@ export class JobDeclarationInvalidError extends UltimateError {
32
33
  });
33
34
  }
34
35
  }
36
+
37
+ /** The required fields a definition lacks, in declaration order. Read off the value, not the type. */
38
+ export function missingJobFields(definition: object): string[] {
39
+ const loose = definition as unknown as Readonly<Record<string, unknown>>;
40
+ const missing: string[] = [];
41
+ if (typeof loose['input'] !== 'object' || loose['input'] === null) missing.push('input');
42
+ if (typeof loose['idempotencyKey'] !== 'function') missing.push('idempotencyKey');
43
+ if (loose['tenant'] === undefined) missing.push('tenant');
44
+ if (typeof loose['retry'] !== 'object' || loose['retry'] === null) missing.push('retry');
45
+ if (typeof loose['run'] !== 'function') missing.push('run');
46
+ return missing;
47
+ }
@@ -1,7 +1,7 @@
1
1
  // Single responsibility: the refusals the operator surface gives — a job that cannot be removed
2
2
  // or promoted, a page it cannot answer — and the one failure it only ever reports, a settled
3
3
  // run's `onSettled` hook. Its own
4
- // module because `errors.ts` sits at the size ceiling; the codes are registered there.
4
+ // module because `errors.ts` sits at the size ceiling; the codes are registered in `error-codes.ts`.
5
5
 
6
6
  import { isFixShellSafe, renderFixShellArg, renderThrowable, UltimateError } from '@ultimat3/core';
7
7
 
@@ -1,6 +1,6 @@
1
1
  // Single responsibility: the three refusals `requeue` — `x jobs retry` — gives before it moves a
2
2
  // row, raised identically by `driver-memory.ts` and `driver-pg.ts`: one answer for both drivers is
3
- // the point, and neither driver imports the other. The codes are registered in `errors.ts`.
3
+ // the point, and neither driver imports the other. The codes are registered in `error-codes.ts`.
4
4
 
5
5
  import { renderCauseValue, renderFixShellArg, UltimateError } from '@ultimat3/core';
6
6
  import { JobDuplicateError } from './errors';
@@ -0,0 +1,87 @@
1
+ // The refusals of durable compensation and at-most-once steps: a step name the framework reserves,
2
+ // a `compensate` its job did not declare, a `once` step whose outcome nobody knows, an unwind that
3
+ // could not finish, and a resolve with nothing to resolve. The codes are in `error-codes.ts`.
4
+
5
+ import { renderCauseValue, renderFixShellArg, UltimateError } from '@ultimat3/core';
6
+
7
+ /** The step the framework writes before the first compensator: the durable "this run unwinds". */
8
+ export const UNWINDING_STEP = 'unwinding';
9
+ /** Each compensator runs as an ordinary step under this prefix, so a replay skips what ran. */
10
+ export const COMPENSATE_PREFIX = 'compensate:';
11
+
12
+ /** A body named a step the framework writes itself. Refused for every job, `compensates` or not. */
13
+ export class StepNameReservedError extends UltimateError {
14
+ constructor(input: { job: string; step: string }) {
15
+ super({
16
+ code: 'X_JOB_DECLARATION_INVALID',
17
+ cause: `job "${input.job}" names a step "${input.step}", and "${UNWINDING_STEP}" and "${COMPENSATE_PREFIX}*" are the names compensation writes`,
18
+ fix: `step.run('<another name>', …) # in job('${input.job}'), instead of "${input.step}": "${UNWINDING_STEP}" and "${COMPENSATE_PREFIX}*" are compensation's`,
19
+ meta: { job: input.job, step: input.step },
20
+ });
21
+ }
22
+ }
23
+
24
+ /** `step.run(…, { compensate })` in a job that never declared it compensates. */
25
+ export class StepCompensateUndeclaredError extends UltimateError {
26
+ constructor(input: { job: string; step: string }) {
27
+ super({
28
+ code: 'X_JOB_DECLARATION_INVALID',
29
+ cause: `job "${input.job}" step "${input.step}" passes compensate, and the job does not declare compensates: true, so nothing would ever run it`,
30
+ fix: `job({ name: '${input.job}', compensates: true, … }) # or drop compensate from step.run('${input.step}', …)`,
31
+ meta: { job: input.job, step: input.step },
32
+ });
33
+ }
34
+ }
35
+
36
+ /** A `once` step has a `started` (or `failed`) record and no `completed` one: did it happen? */
37
+ export class StepOutcomeUnknownError extends UltimateError {
38
+ constructor(input: { job: string; step: string }) {
39
+ super({
40
+ code: 'X_STEP_OUTCOME_UNKNOWN',
41
+ cause: `job "${input.job}" step "${input.step}" is declared once, it started on an earlier attempt and never completed, so whether it ran is unknown and running it again could do it twice`,
42
+ fix: `x jobs resolve-step <id> ${renderFixShellArg(input.step, '<step>')} --ran --output '<json>' # or --not-ran to run it again; then x jobs retry <id>`,
43
+ meta: { job: input.job, step: input.step },
44
+ });
45
+ }
46
+ }
47
+
48
+ /**
49
+ * The unwind stopped: a compensator spent the unwind's attempts, or it reached a `once` step whose
50
+ * outcome is unknown — undoing something that may not have happened is a person's call.
51
+ */
52
+ export class JobCompensationFailedError extends UltimateError {
53
+ constructor(input: {
54
+ job: string;
55
+ step: string;
56
+ outcome: 'failed' | 'unknown';
57
+ reason: string;
58
+ }) {
59
+ const unknown = input.outcome === 'unknown';
60
+ super({
61
+ code: 'X_JOB_COMPENSATION_FAILED',
62
+ cause: unknown
63
+ ? `job "${input.job}" is unwinding and step "${input.step}" is once with an unknown outcome (X_STEP_OUTCOME_UNKNOWN), so its compensator cannot know whether there is anything to undo`
64
+ : `job "${input.job}" is unwinding and the compensator of step "${input.step}" failed: ${input.reason}`,
65
+ fix: unknown
66
+ ? `x jobs resolve-step <id> ${renderFixShellArg(input.step, '<step>')} --ran --output '<json>' # or --not-ran; then x jobs retry <id>, and completed compensators replay`
67
+ : `x jobs retry <id> # once the compensate of step.run('${input.step}', …) in job('${input.job}') is repaired: completed compensators replay`,
68
+ meta: { job: input.job, step: input.step, outcome: input.outcome },
69
+ });
70
+ }
71
+ }
72
+
73
+ /** Whether `name` is one the framework writes: refused when a body asks for it. */
74
+ export const isReservedStepName = (name: string): boolean =>
75
+ name === UNWINDING_STEP || name.startsWith(COMPENSATE_PREFIX);
76
+
77
+ /** `x jobs resolve-step` on a running job, or on a step that has no unknown outcome to answer. */
78
+ export class StepNotResolvableError extends UltimateError {
79
+ constructor(input: { jobId: string; step: string; state: string; status: string }) {
80
+ super({
81
+ code: 'X_STEP_NOT_RESOLVABLE',
82
+ cause: `job ${renderCauseValue(input.jobId)} is ${input.state} and its step ${renderCauseValue(input.step)} is ${input.status}: only a step that started (or failed) and never completed, on a job no worker holds, has an outcome to resolve`,
83
+ fix: `x jobs show ${renderFixShellArg(input.jobId, '<job id>')} --json # read the steps: resolve one whose status is started or failed, once the job has settled`,
84
+ meta: { jobId: input.jobId, step: input.step, state: input.state, status: input.status },
85
+ });
86
+ }
87
+ }
@@ -0,0 +1,35 @@
1
+ // The two refusals a step answer can raise: a value JSON cannot hold, and a waited event its schema
2
+ // refuses. Both are terminal, and their codes stay declared in `error-codes.ts`.
3
+
4
+ import { UltimateError } from '@ultimat3/core';
5
+
6
+ /** `step.run`'s body answered what a store cannot hold faithfully, so no attempt could replay it. */
7
+ export class StepOutputUnserializableError extends UltimateError {
8
+ constructor(input: { job: string; step: string; reason: string }) {
9
+ super({
10
+ code: 'X_STEP_OUTPUT_UNSERIALIZABLE',
11
+ cause: `job "${input.job}" step "${input.step}" answered a value JSON cannot hold (${input.reason}), so no attempt could replay it`,
12
+ fix: `step.run('${input.step}', () => String(id)) # return plain JSON: String(id) for a bigint, the ids rather than the object graph, null rather than NaN or an invalid Date, text with no NUL and no lone surrogate`,
13
+ meta: { job: input.job, step: input.step },
14
+ });
15
+ }
16
+ }
17
+
18
+ /**
19
+ * The event `step.waitForEvent` found fails the wait's own schema, on a hit or on a replay.
20
+ *
21
+ * Terminal rather than skipped: skipping hides the publisher's bug until the timeout. `issues` come
22
+ * from `formatIssues`, and the payload itself is never echoed, because an event can carry anything.
23
+ * The wait records the refused event as its floor, so after the retry the fix names, it answers to
24
+ * the next one published rather than finding this one again.
25
+ */
26
+ export class JobEventInvalidError extends UltimateError {
27
+ constructor(input: { job: string; step: string; event: string; issues: readonly string[] }) {
28
+ super({
29
+ code: 'X_JOB_EVENT_INVALID',
30
+ cause: `job "${input.job}" step "${input.step}" found event "${input.event}", and it fails the wait's schema: ${input.issues.join('; ')}`,
31
+ fix: `x jobs retry <id> # after the publisher of "${input.event}" sends what the schema of step.waitForEvent('${input.step}', …) declares, or that schema is widened`,
32
+ meta: { job: input.job, step: input.step, event: input.event },
33
+ });
34
+ }
35
+ }
@@ -1,6 +1,6 @@
1
1
  // The one refusal for a job whose PAYLOAD and declared tenant disagree — a webhook delivery's
2
2
  // `orgId` that is not the org the run is under, a tenant that derives no org from the payload, an
3
- // agent actor resolved in another org. Its code stays declared in `errors.ts`, classified terminal.
3
+ // agent actor resolved in another org. Its code stays declared in `error-codes.ts`, classified terminal.
4
4
 
5
5
  import { UltimateError } from '@ultimat3/core';
6
6
 
package/src/errors.ts CHANGED
@@ -1,166 +1,8 @@
1
- // The X_* codes owned by @ultimat3/jobs. Every one names the command or code change that
2
- // fixes it — a job failure an agent cannot act on is a job failure that gets retried forever.
3
- import { registerErrorCodes, registerErrorRetry, UltimateError } from '@ultimat3/core';
4
-
5
- /** Codes this package declares and owns. */
6
- export const JOB_OWNED_ERROR_CODES = [
7
- 'X_JOB_DUPLICATE',
8
- 'X_STEP_DUPLICATE',
9
- 'X_JOB_TIMEOUT',
10
- 'X_JOB_MAX_ATTEMPTS',
11
- 'X_DRIVER_UNAVAILABLE',
12
- 'X_IDEMPOTENCY_REQUIRED',
13
- 'X_JOB_TENANT_REQUIRED',
14
- 'X_JOB_CONCURRENCY_UNENFORCEABLE',
15
- 'X_JOB_LEASE_LOST',
16
- 'X_JOB_SLOT_LOST',
17
- 'X_JOB_NOT_CANCELLABLE',
18
- 'X_OUTBOX_NO_TX',
19
- 'X_BACKFILL_PENDING',
20
- 'X_BACKFILL_APPLIED',
21
- 'X_BACKFILL_ENVIRONMENT',
22
- 'X_BACKFILL_MIGRATION_PENDING',
23
- 'X_BACKFILL_RUNNING',
24
- 'X_BACKFILL_STALLED',
25
- 'X_BACKFILL_UNKNOWN',
26
- 'X_JOB_ROW_STATUS_UNKNOWN',
27
- 'X_ACTION_JOB_UNBRIDGED',
28
- 'X_JOB_CLAIM_QUEUES_EMPTY',
29
- 'X_WEBHOOK_ENDPOINT_UNKNOWN',
30
- 'X_WEBHOOK_ENDPOINT_INVALID',
31
- 'X_WEBHOOK_ENDPOINT_DISABLED',
32
- 'X_WEBHOOK_EVENT_UNKNOWN',
33
- 'X_WEBHOOK_EVENT_INVALID',
34
- 'X_WEBHOOK_DELIVERY_FAILED',
35
- 'X_WEBHOOK_DELIVERY_THROTTLED',
36
- 'X_WEBHOOK_DELIVERY_REJECTED',
37
- 'X_EXPORT_ROW_INVALID',
38
- 'X_EXPORT_PART_TOO_LARGE',
39
- 'X_JOB_DECLARATION_INVALID',
40
- 'X_JOB_NOT_REQUEUEABLE',
41
- 'X_JOB_KEY_BUSY',
42
- 'X_JOB_NOT_REMOVABLE',
43
- 'X_JOB_NOT_PROMOTABLE',
44
- 'X_JOB_PAGE_INVALID',
45
- 'X_JOB_ON_SETTLED_FAILED',
46
- 'X_JOB_NOT_FOUND',
47
- 'X_JOB_TENANT_MISMATCH',
48
- ] as const;
49
-
50
- /**
51
- * `X_NOT_IMPLEMENTED`, `X_ABORTED` and `X_DRAINING` are `@ultimat3/core`'s. The first is thrown as
52
- * core's own `NotImplementedError` (`inspect.ts`), the other two by
53
- * `JobAbortedError` and `JobDrainedError` below; jobs keeps no title for any of the three, because the copy this file used to hold was a second title that nothing would have failed
54
- * on once core's changed. Listed here all the same, so `JobErrorCode` can name every code a job
55
- * can see — `X_DRAINING` was thrown for a day before it was, and the type said it could not be.
56
- */
57
- export const JOB_BORROWED_ERROR_CODES = ['X_NOT_IMPLEMENTED', 'X_ABORTED', 'X_DRAINING'] as const;
58
-
59
- /** Every code jobs can throw: the ones it owns plus the ones it borrows. */
60
- export const JOB_ERROR_CODES = [...JOB_OWNED_ERROR_CODES, ...JOB_BORROWED_ERROR_CODES] as const;
61
-
62
- export type JobOwnedErrorCode = (typeof JOB_OWNED_ERROR_CODES)[number];
63
- export type JobErrorCode = (typeof JOB_ERROR_CODES)[number];
64
-
65
- export const JOB_ERROR_TITLES: Readonly<Record<JobOwnedErrorCode, string>> = {
66
- X_JOB_DUPLICATE: 'a live key already has a job',
67
- X_STEP_DUPLICATE: 'two step.run calls share a name',
68
- X_JOB_TIMEOUT: 'a job exceeded its wall-clock limit',
69
- X_JOB_MAX_ATTEMPTS: 'the job exhausted its retries',
70
- X_DRIVER_UNAVAILABLE: 'the queue driver is unreachable',
71
- X_IDEMPOTENCY_REQUIRED: 'the job has no idempotencyKey',
72
- X_JOB_TENANT_REQUIRED: 'the job declares no tenant',
73
- X_JOB_CONCURRENCY_UNENFORCEABLE: 'job.concurrency is declared and cannot be enforced',
74
- X_JOB_LEASE_LOST: 'the queue took this job back mid-run',
75
- X_JOB_SLOT_LOST: 'the fleet concurrency slot was taken by another worker',
76
- X_JOB_NOT_CANCELLABLE: 'the job cannot be cancelled',
77
- X_OUTBOX_NO_TX: 'enqueue outside a transaction',
78
- X_BACKFILL_PENDING: 'a declared backfill has never completed',
79
- X_BACKFILL_APPLIED: 'the ledger already holds a completed pass',
80
- X_BACKFILL_ENVIRONMENT: 'the backfill is not declared for this environment',
81
- X_BACKFILL_MIGRATION_PENDING: 'the migration this backfill requires is not applied',
82
- X_BACKFILL_RUNNING: 'a pass under this name is already live',
83
- X_BACKFILL_STALLED: 'the sweep ended with rows its own count still matches',
84
- X_BACKFILL_UNKNOWN: 'no declaration carries this backfill name',
85
- X_JOB_ROW_STATUS_UNKNOWN: 'a queue row carries a status this build does not know',
86
- X_ACTION_JOB_UNBRIDGED: 'an action projection was registered as a job',
87
- X_JOB_CLAIM_QUEUES_EMPTY: 'a claim named no queue, and the drivers do not agree what that means',
88
- X_WEBHOOK_ENDPOINT_UNKNOWN: 'no endpoint carries this id',
89
- X_WEBHOOK_ENDPOINT_INVALID: 'the endpoint cannot be delivered to as declared',
90
- X_WEBHOOK_ENDPOINT_DISABLED: 'the endpoint takes no deliveries',
91
- X_WEBHOOK_EVENT_UNKNOWN: 'no event carries this id',
92
- X_WEBHOOK_EVENT_INVALID: 'the event cannot be signed as declared',
93
- X_WEBHOOK_DELIVERY_FAILED: 'the endpoint did not accept the delivery',
94
- X_WEBHOOK_DELIVERY_THROTTLED: 'the endpoint asked for the delivery later, and said when',
95
- X_WEBHOOK_DELIVERY_REJECTED: 'the endpoint refused the delivery in a way a retry cannot change',
96
- X_EXPORT_ROW_INVALID: 'row() answered something the declared columns do not carry',
97
- X_EXPORT_PART_TOO_LARGE: 'one page encoded to more bytes than a part may hold',
98
- X_JOB_DECLARATION_INVALID: 'the job declaration is missing or mis-declares a field',
99
- X_JOB_NOT_REQUEUEABLE: 'the job is still live and cannot be requeued',
100
- X_JOB_KEY_BUSY: 'the concurrency key already holds its limit of runs',
101
- X_JOB_NOT_REMOVABLE: 'the job is running and cannot be removed',
102
- X_JOB_NOT_PROMOTABLE: 'the job is not waiting on its run time',
103
- X_JOB_PAGE_INVALID: 'a job list page was asked for outside its bounds',
104
- X_JOB_ON_SETTLED_FAILED: 'the onSettled hook failed after the run was settled',
105
- X_JOB_NOT_FOUND: 'the queue holds no job with this id',
106
- X_JOB_TENANT_MISMATCH: "a job's payload names an org other than the tenant it runs under",
107
- };
108
-
109
- // One unconditional call, so a second package claiming one of jobs' codes throws
110
- // X_ERROR_CODE_DUPLICATE instead of losing silently to whichever module imported first.
111
- registerErrorCodes(
112
- Object.fromEntries(Object.entries(JOB_ERROR_TITLES).map(([code, title]) => [code, { title }])),
113
- );
114
-
115
- /**
116
- * The codes of this package's that can be thrown INSIDE a job body, classified — `executeJob`
117
- * reads this, so a `terminal` one dead-letters on the attempt it happened instead of spending the
118
- * whole policy on an answer that cannot change. Same rule every package uses: retryable means the
119
- * same code, run again, has a real chance of a different answer.
120
- *
121
- * Two are deliberately absent. `X_JOB_LEASE_LOST` and `X_JOB_SLOT_LOST` mean the row is somebody
122
- * else's now, so this attempt's verdict is not this attempt's to give: dead-lettering would settle
123
- * a job another worker is running. They keep the attempt-count path, which ends in the queue
124
- * re-delivering — the honest outcome for "we stopped owning it".
125
- */
126
- registerErrorRetry({
127
- X_JOB_TIMEOUT: 'retryable',
128
- X_DRIVER_UNAVAILABLE: 'retryable',
129
- // A second `step.run` under one name is a defect in the handler, replayed identically forever.
130
- X_STEP_DUPLICATE: 'terminal',
131
- // A sweep whose source ran dry while its own count still matches rows: the next attempt resumes
132
- // at the cursor that just ran dry and diverges again.
133
- X_BACKFILL_STALLED: 'terminal',
134
- X_BACKFILL_ENVIRONMENT: 'terminal',
135
- X_BACKFILL_APPLIED: 'terminal',
136
- // The one retryable webhook code, and the reason the split exists at all: a 5xx, a 429 or a
137
- // connection that never opened is the same request landing later, which is what the job's retry
138
- // policy is for.
139
- X_WEBHOOK_DELIVERY_FAILED: 'retryable',
140
- // `retry-after` and not `retryable`: the receiver NAMED a delay, so `statedDelayMs` reads
141
- // `meta.retryAfterSeconds` and the nack waits that long (clamped by the policy's `maxDelay`)
142
- // instead of guessing a curve against an answer it was already given.
143
- X_WEBHOOK_DELIVERY_THROTTLED: 'retry-after',
144
- // The rest are `terminal` and every one is LISTED rather than left to the default, because
145
- // `classifyThrown` reads an unregistered code carrying `terminal` as UNCLASSIFIED — which spends
146
- // a delivery's whole retry policy re-proving that a 404 endpoint is still a 404.
147
- X_WEBHOOK_DELIVERY_REJECTED: 'terminal',
148
- X_WEBHOOK_ENDPOINT_UNKNOWN: 'terminal',
149
- X_WEBHOOK_ENDPOINT_INVALID: 'terminal',
150
- X_WEBHOOK_ENDPOINT_DISABLED: 'terminal',
151
- X_WEBHOOK_EVENT_UNKNOWN: 'terminal',
152
- X_WEBHOOK_EVENT_INVALID: 'terminal',
153
- // Both export codes are refusals of the DECLARATION and not of the data: the same `row()` over
154
- // the same page answers the same way on every attempt, so an unclassified reading would spend
155
- // the whole retry policy proving it.
156
- X_EXPORT_ROW_INVALID: 'terminal',
157
- X_EXPORT_PART_TOO_LARGE: 'terminal',
158
- // The worker settles a busy key itself; listed so a body that RETHROWS one it was handed stops.
159
- X_JOB_KEY_BUSY: 'terminal',
160
- // The payload names the wrong org, or none its tenant can run under — a fact about the ROW, so
161
- // every attempt and every `x jobs retry` is refused identically: the repair is a new enqueue.
162
- X_JOB_TENANT_MISMATCH: 'terminal',
163
- });
1
+ // The error classes @ultimat3/jobs throws. Every one names the command or code change that fixes
2
+ // it — a job failure an agent cannot act on is a job failure that gets retried forever. The codes,
3
+ // their titles and their registration are `./error-codes`, imported here for that side effect.
4
+ import { UltimateError } from '@ultimat3/core';
5
+ import './error-codes';
164
6
 
165
7
  // No `docs:` on any class below, here or in `backfill-errors.ts`. `UltimateError` fills it from
166
8
  // `describeErrorCode(code).docs`, which is `@ultimat3/core`'s `ERROR_DOCS_URL` — one page for every
@@ -262,14 +104,16 @@ export class StepDuplicateError extends UltimateError {
262
104
  }
263
105
 
264
106
  export class JobTimeoutError extends UltimateError {
265
- constructor(input: { job: string; timeoutMs: number; step?: string }) {
107
+ /** `fix` for a ceiling the job's `timeout` does not set: the `onSettled` hook's. */
108
+ constructor(input: { job: string; timeoutMs: number; step?: string; fix?: string }) {
266
109
  super({
267
110
  code: 'X_JOB_TIMEOUT',
268
111
  cause:
269
112
  input.step === undefined
270
113
  ? `job "${input.job}" exceeded its ${input.timeoutMs}ms timeout`
271
114
  : `job "${input.job}" step "${input.step}" exceeded its ${input.timeoutMs}ms timeout`,
272
- fix: `raise timeout on the job definition, or split the work into step.run() calls`,
115
+ fix:
116
+ input.fix ?? `raise timeout on the job definition, or split the work into step.run() calls`,
273
117
  });
274
118
  }
275
119
  }
@@ -450,6 +294,22 @@ export class CancelUnsupportedError extends UltimateError {
450
294
  }
451
295
  }
452
296
 
297
+ /**
298
+ * `enqueueMany` handed more inputs than one call takes. Refused before any key is derived or any
299
+ * row is written, so nothing of the batch lands: a list that size is a table, and a table is a
300
+ * `backfill()` that pages it, not one call holding every row in memory.
301
+ */
302
+ export class JobEnqueueBatchTooLargeError extends UltimateError {
303
+ constructor(input: { job: string; count: number; max: number }) {
304
+ super({
305
+ code: 'X_JOB_ENQUEUE_BATCH_TOO_LARGE',
306
+ cause: `${input.job}.enqueueMany was handed ${input.count} inputs, past the ${input.max} one call may stage`,
307
+ fix: `await handle.enqueueMany(inputs.slice(i, i + ${input.max})) for i = 0, ${input.max}, … with handle = ${input.job} — or page the source with backfill() from @ultimat3/jobs`,
308
+ meta: { job: input.job, count: input.count, max: input.max },
309
+ });
310
+ }
311
+ }
312
+
453
313
  /** An outbox enqueue happened with no ambient transaction to join. */
454
314
  export class OutboxNoTxError extends UltimateError {
455
315
  constructor(input: { job: string }) {