@inf-minds/jobs 0.0.1

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 (76) hide show
  1. package/dist/artifact-cleanup.d.ts +30 -0
  2. package/dist/artifact-cleanup.d.ts.map +1 -0
  3. package/dist/artifact-cleanup.js +44 -0
  4. package/dist/artifact-cleanup.js.map +1 -0
  5. package/dist/artifact-manager.d.ts +41 -0
  6. package/dist/artifact-manager.d.ts.map +1 -0
  7. package/dist/artifact-manager.js +254 -0
  8. package/dist/artifact-manager.js.map +1 -0
  9. package/dist/artifact-materializer.d.ts +21 -0
  10. package/dist/artifact-materializer.d.ts.map +1 -0
  11. package/dist/artifact-materializer.js +33 -0
  12. package/dist/artifact-materializer.js.map +1 -0
  13. package/dist/artifact-router.d.ts +131 -0
  14. package/dist/artifact-router.d.ts.map +1 -0
  15. package/dist/artifact-router.js +4 -0
  16. package/dist/artifact-router.js.map +1 -0
  17. package/dist/default-artifact-router.d.ts +26 -0
  18. package/dist/default-artifact-router.d.ts.map +1 -0
  19. package/dist/default-artifact-router.js +125 -0
  20. package/dist/default-artifact-router.js.map +1 -0
  21. package/dist/event-appender.d.ts +23 -0
  22. package/dist/event-appender.d.ts.map +1 -0
  23. package/dist/event-appender.js +120 -0
  24. package/dist/event-appender.js.map +1 -0
  25. package/dist/index.d.ts +16 -0
  26. package/dist/index.d.ts.map +1 -0
  27. package/dist/index.js +28 -0
  28. package/dist/index.js.map +1 -0
  29. package/dist/job-context.d.ts +58 -0
  30. package/dist/job-context.d.ts.map +1 -0
  31. package/dist/job-context.js +133 -0
  32. package/dist/job-context.js.map +1 -0
  33. package/dist/job-manager.d.ts +23 -0
  34. package/dist/job-manager.d.ts.map +1 -0
  35. package/dist/job-manager.js +145 -0
  36. package/dist/job-manager.js.map +1 -0
  37. package/dist/job-runner.d.ts +32 -0
  38. package/dist/job-runner.d.ts.map +1 -0
  39. package/dist/job-runner.js +187 -0
  40. package/dist/job-runner.js.map +1 -0
  41. package/dist/job-worker.d.ts +79 -0
  42. package/dist/job-worker.d.ts.map +1 -0
  43. package/dist/job-worker.js +134 -0
  44. package/dist/job-worker.js.map +1 -0
  45. package/dist/mind-worker.d.ts +63 -0
  46. package/dist/mind-worker.d.ts.map +1 -0
  47. package/dist/mind-worker.js +99 -0
  48. package/dist/mind-worker.js.map +1 -0
  49. package/dist/schema.d.ts +1143 -0
  50. package/dist/schema.d.ts.map +1 -0
  51. package/dist/schema.js +225 -0
  52. package/dist/schema.js.map +1 -0
  53. package/dist/types.d.ts +434 -0
  54. package/dist/types.d.ts.map +1 -0
  55. package/dist/types.js +24 -0
  56. package/dist/types.js.map +1 -0
  57. package/drizzle/0001_create_jobs_tables.sql +70 -0
  58. package/drizzle/0002_coordinator_tables.sql +78 -0
  59. package/drizzle/0003_artifacts.sql +24 -0
  60. package/drizzle/0004_kernel.sql +28 -0
  61. package/drizzle/0005_artifact_routing.sql +29 -0
  62. package/package.json +48 -0
  63. package/src/artifact-cleanup.ts +85 -0
  64. package/src/artifact-manager.ts +346 -0
  65. package/src/artifact-materializer.ts +64 -0
  66. package/src/artifact-router.ts +151 -0
  67. package/src/default-artifact-router.ts +186 -0
  68. package/src/event-appender.ts +158 -0
  69. package/src/index.ts +136 -0
  70. package/src/job-context.ts +195 -0
  71. package/src/job-manager.ts +179 -0
  72. package/src/job-runner.ts +260 -0
  73. package/src/job-worker.ts +252 -0
  74. package/src/mind-worker.ts +152 -0
  75. package/src/schema.ts +290 -0
  76. package/src/types.ts +542 -0
@@ -0,0 +1,179 @@
1
+ // ABOUTME: JobManager implementation for job CRUD operations
2
+ // ABOUTME: Provides create, get, list, cancel, complete, and fail operations
3
+
4
+ import { eq, and, desc } from 'drizzle-orm';
5
+ import { jobs, JOB_STATUS, type Job, type JobStatus } from './schema.js';
6
+ import type {
7
+ JobManager,
8
+ CreateJobOptions,
9
+ ListJobsOptions,
10
+ ListJobsResult,
11
+ DbClient,
12
+ } from './types.js';
13
+
14
+ /**
15
+ * Error thrown when a job operation fails.
16
+ */
17
+ export class JobError extends Error {
18
+ constructor(
19
+ message: string,
20
+ public readonly code: string,
21
+ public readonly jobId?: string
22
+ ) {
23
+ super(message);
24
+ this.name = 'JobError';
25
+ }
26
+ }
27
+
28
+ /**
29
+ * Creates a JobManager instance.
30
+ *
31
+ * @example
32
+ * import { drizzle } from 'drizzle-orm/postgres-js';
33
+ * import postgres from 'postgres';
34
+ * import { createJobManager } from '@inf-minds/jobs';
35
+ *
36
+ * const client = postgres(process.env.DATABASE_URL);
37
+ * const db = drizzle(client);
38
+ * const jobManager = createJobManager(db);
39
+ */
40
+ export function createJobManager(db: DbClient): JobManager {
41
+ return new JobManagerImpl(db);
42
+ }
43
+
44
+ class JobManagerImpl implements JobManager {
45
+ constructor(private readonly db: DbClient) {}
46
+
47
+ async create<TInput = unknown>(options: CreateJobOptions<TInput>): Promise<Job> {
48
+ // Validate required fields
49
+ if (!options.type || options.type.trim() === '') {
50
+ throw new JobError('Job type is required', 'INVALID_TYPE');
51
+ }
52
+
53
+ if (!options.accountId || options.accountId.trim() === '') {
54
+ throw new JobError('Account ID is required', 'INVALID_ACCOUNT_ID');
55
+ }
56
+
57
+ const result = await this.db
58
+ .insert(jobs)
59
+ .values({
60
+ type: options.type,
61
+ accountId: options.accountId,
62
+ status: JOB_STATUS.PENDING,
63
+ input: options.input ?? null,
64
+ // Kernel orchestration fields
65
+ parentJobId: options.parentJobId ?? null,
66
+ nodeId: options.nodeId ?? null,
67
+ graphState: options.graphState ?? null,
68
+ artifactBasePath: options.artifactBasePath ?? null,
69
+ })
70
+ .returning();
71
+
72
+ const job = result[0];
73
+ if (!job) {
74
+ throw new JobError('Failed to create job', 'CREATE_FAILED');
75
+ }
76
+
77
+ return job as Job;
78
+ }
79
+
80
+ async get(jobId: string): Promise<Job | null> {
81
+ const result = await this.db
82
+ .select()
83
+ .from(jobs)
84
+ .where(eq(jobs.id, jobId));
85
+
86
+ return (result[0] as Job) ?? null;
87
+ }
88
+
89
+ async list(options: ListJobsOptions): Promise<ListJobsResult> {
90
+ const { accountId, type, status, limit = 50, offset = 0 } = options;
91
+
92
+ // Build where conditions
93
+ const conditions = [eq(jobs.accountId, accountId)];
94
+
95
+ if (type) {
96
+ conditions.push(eq(jobs.type, type));
97
+ }
98
+
99
+ if (status) {
100
+ conditions.push(eq(jobs.status, status));
101
+ }
102
+
103
+ const whereClause = conditions.length === 1 ? conditions[0]! : and(...conditions);
104
+
105
+ const result = await this.db
106
+ .select()
107
+ .from(jobs)
108
+ .where(whereClause)
109
+ .orderBy(desc(jobs.createdAt))
110
+ .limit(limit)
111
+ .offset(offset);
112
+
113
+ return {
114
+ jobs: result as Job[],
115
+ };
116
+ }
117
+
118
+ async cancel(jobId: string): Promise<void> {
119
+ const job = await this.get(jobId);
120
+
121
+ if (!job) {
122
+ throw new JobError('Job not found', 'NOT_FOUND', jobId);
123
+ }
124
+
125
+ if (!this.isCancellable(job.status as JobStatus)) {
126
+ throw new JobError(
127
+ `Cannot cancel job with status '${job.status}'`,
128
+ 'NOT_CANCELLABLE',
129
+ jobId
130
+ );
131
+ }
132
+
133
+ await this.db
134
+ .update(jobs)
135
+ .set({
136
+ status: JOB_STATUS.CANCELLED,
137
+ completedAt: new Date(),
138
+ })
139
+ .where(eq(jobs.id, jobId));
140
+ }
141
+
142
+ async updateProgress(jobId: string, progress: number): Promise<void> {
143
+ const clampedProgress = Math.max(0, Math.min(1, progress));
144
+
145
+ await this.db
146
+ .update(jobs)
147
+ .set({
148
+ progress: clampedProgress,
149
+ })
150
+ .where(eq(jobs.id, jobId));
151
+ }
152
+
153
+ async complete<TOutput = unknown>(jobId: string, output?: TOutput): Promise<void> {
154
+ await this.db
155
+ .update(jobs)
156
+ .set({
157
+ status: JOB_STATUS.COMPLETED,
158
+ output: output ?? null,
159
+ progress: 1.0,
160
+ completedAt: new Date(),
161
+ })
162
+ .where(eq(jobs.id, jobId));
163
+ }
164
+
165
+ async fail(jobId: string, error: string): Promise<void> {
166
+ await this.db
167
+ .update(jobs)
168
+ .set({
169
+ status: JOB_STATUS.FAILED,
170
+ error,
171
+ completedAt: new Date(),
172
+ })
173
+ .where(eq(jobs.id, jobId));
174
+ }
175
+
176
+ private isCancellable(status: JobStatus): boolean {
177
+ return status === JOB_STATUS.PENDING || status === JOB_STATUS.RUNNING;
178
+ }
179
+ }
@@ -0,0 +1,260 @@
1
+ // ABOUTME: JobRunner implementation for direct execution model
2
+ // ABOUTME: Orchestrates job lifecycle - create, run handler, complete/fail
3
+
4
+ import { createJobContext, JobAbortedError } from './job-context.js';
5
+ import { JOB_STATUS } from './schema.js';
6
+ import type {
7
+ JobRunner,
8
+ JobRunnerOptions,
9
+ RunJobOptions,
10
+ RunJobResult,
11
+ JobHandler,
12
+ JobManager,
13
+ EventAppender,
14
+ } from './types.js';
15
+ import type { ArtifactManager } from './artifact-manager.js';
16
+ import type { ArtifactMaterializer } from './artifact-materializer.js';
17
+ import type { MaterializedArtifacts } from './job-context.js';
18
+
19
+ /**
20
+ * Error thrown when a job cannot be found or resumed.
21
+ */
22
+ export class JobRunnerError extends Error {
23
+ constructor(
24
+ message: string,
25
+ public readonly code: string,
26
+ public readonly jobId?: string
27
+ ) {
28
+ super(message);
29
+ this.name = 'JobRunnerError';
30
+ }
31
+ }
32
+
33
+ /**
34
+ * Creates a JobRunner for direct execution of jobs.
35
+ *
36
+ * @example
37
+ * ```typescript
38
+ * const runner = createJobRunner({
39
+ * jobManager,
40
+ * eventAppender,
41
+ * relayCallbackUrl: 'https://relay.example.com/callback',
42
+ * });
43
+ *
44
+ * const result = await runner.run(
45
+ * { type: 'process-image', accountId, input: { url: '...' } },
46
+ * async (ctx) => {
47
+ * await ctx.log('Starting');
48
+ * await ctx.progress(0.5);
49
+ * return { result: 'done' };
50
+ * }
51
+ * );
52
+ * ```
53
+ */
54
+ export function createJobRunner(options: JobRunnerOptions): JobRunner {
55
+ return new JobRunnerImpl(options);
56
+ }
57
+
58
+ class JobRunnerImpl implements JobRunner {
59
+ private readonly jobManager: JobManager;
60
+ private readonly eventAppender: EventAppender;
61
+ private readonly relayCallbackUrl?: string;
62
+ private readonly fetchFn: typeof globalThis.fetch;
63
+ private readonly artifactManager?: ArtifactManager;
64
+ private readonly artifactMaterializer?: ArtifactMaterializer;
65
+
66
+ constructor(options: JobRunnerOptions) {
67
+ this.jobManager = options.jobManager;
68
+ this.eventAppender = options.eventAppender;
69
+ this.relayCallbackUrl = options.relayCallbackUrl;
70
+ this.fetchFn = options.fetch ?? globalThis.fetch;
71
+ this.artifactManager = options.artifactManager;
72
+ this.artifactMaterializer = options.artifactMaterializer;
73
+ }
74
+
75
+ async run<TInput = unknown, TOutput = unknown>(
76
+ options: RunJobOptions<TInput>,
77
+ handler: JobHandler<TInput, TOutput>
78
+ ): Promise<RunJobResult<TOutput>> {
79
+ // Create the job
80
+ const job = await this.jobManager.create({
81
+ type: options.type,
82
+ accountId: options.accountId,
83
+ input: options.input,
84
+ });
85
+
86
+ // Execute with the newly created job
87
+ return this.executeJob(job.id, handler, options.signal);
88
+ }
89
+
90
+ async resume<TInput = unknown, TOutput = unknown>(
91
+ jobId: string,
92
+ handler: JobHandler<TInput, TOutput>,
93
+ signal?: AbortSignal
94
+ ): Promise<RunJobResult<TOutput>> {
95
+ // Fetch existing job
96
+ const job = await this.jobManager.get(jobId);
97
+
98
+ if (!job) {
99
+ throw new JobRunnerError(
100
+ `Job not found: ${jobId}`,
101
+ 'JOB_NOT_FOUND',
102
+ jobId
103
+ );
104
+ }
105
+
106
+ // Validate job can be resumed
107
+ if (job.status === JOB_STATUS.COMPLETED) {
108
+ throw new JobRunnerError(
109
+ `Job already completed: ${jobId}`,
110
+ 'JOB_ALREADY_COMPLETED',
111
+ jobId
112
+ );
113
+ }
114
+
115
+ if (job.status === JOB_STATUS.CANCELLED) {
116
+ throw new JobRunnerError(
117
+ `Job was cancelled: ${jobId}`,
118
+ 'JOB_CANCELLED',
119
+ jobId
120
+ );
121
+ }
122
+
123
+ if (job.status === JOB_STATUS.FAILED) {
124
+ throw new JobRunnerError(
125
+ `Job already failed: ${jobId}`,
126
+ 'JOB_ALREADY_FAILED',
127
+ jobId
128
+ );
129
+ }
130
+
131
+ return this.executeJob(jobId, handler, signal);
132
+ }
133
+
134
+ private async executeJob<TInput = unknown, TOutput = unknown>(
135
+ jobId: string,
136
+ handler: JobHandler<TInput, TOutput>,
137
+ signal?: AbortSignal
138
+ ): Promise<RunJobResult<TOutput>> {
139
+ // Fetch the job (may have been just created or being resumed)
140
+ const job = await this.jobManager.get(jobId);
141
+
142
+ if (!job) {
143
+ throw new JobRunnerError(
144
+ `Job not found: ${jobId}`,
145
+ 'JOB_NOT_FOUND',
146
+ jobId
147
+ );
148
+ }
149
+
150
+ // Create abort controller for this execution
151
+ const controller = new AbortController();
152
+
153
+ // Link to external signal if provided
154
+ if (signal) {
155
+ if (signal.aborted) {
156
+ controller.abort();
157
+ } else {
158
+ signal.addEventListener('abort', () => controller.abort(), { once: true });
159
+ }
160
+ }
161
+
162
+ // Create relay callback handler if configured
163
+ const onEvent = this.relayCallbackUrl
164
+ ? async (event: { type: string; payload: unknown; offset: string }) => {
165
+ try {
166
+ await this.fetchFn(`${this.relayCallbackUrl}/${jobId}`, {
167
+ method: 'POST',
168
+ headers: { 'Content-Type': 'application/json' },
169
+ body: JSON.stringify(event),
170
+ });
171
+ } catch {
172
+ // Fire and forget - don't fail job on relay errors
173
+ }
174
+ }
175
+ : undefined;
176
+
177
+ // Materialize dependency artifacts if configured
178
+ let materializedArtifacts: MaterializedArtifacts | undefined;
179
+ if (this.artifactMaterializer) {
180
+ materializedArtifacts = await this.artifactMaterializer.materialize(jobId);
181
+ }
182
+
183
+ // Create context for handler
184
+ const ctx = createJobContext<TInput>({
185
+ job,
186
+ eventAppender: this.eventAppender,
187
+ jobManager: this.jobManager,
188
+ signal: controller.signal,
189
+ onEvent,
190
+ artifactManager: this.artifactManager,
191
+ materializedArtifacts,
192
+ });
193
+
194
+ try {
195
+ // Emit start event
196
+ await this.eventAppender.append({
197
+ jobId,
198
+ type: 'started',
199
+ payload: { timestamp: new Date().toISOString() },
200
+ });
201
+
202
+ // Execute handler
203
+ const output = await handler(ctx);
204
+
205
+ // Mark job as completed
206
+ await this.jobManager.complete(jobId, output);
207
+
208
+ // Emit completion event
209
+ await this.eventAppender.append({
210
+ jobId,
211
+ type: 'completed',
212
+ payload: { output, timestamp: new Date().toISOString() },
213
+ });
214
+
215
+ // Fetch updated job
216
+ const completedJob = await this.jobManager.get(jobId);
217
+
218
+ return {
219
+ job: completedJob ?? job,
220
+ output,
221
+ };
222
+ } catch (error) {
223
+ // Handle abort
224
+ if (error instanceof JobAbortedError || controller.signal.aborted) {
225
+ await this.jobManager.cancel(jobId);
226
+
227
+ await this.eventAppender.append({
228
+ jobId,
229
+ type: 'cancelled',
230
+ payload: { timestamp: new Date().toISOString() },
231
+ });
232
+
233
+ const cancelledJob = await this.jobManager.get(jobId);
234
+
235
+ return {
236
+ job: cancelledJob ?? job,
237
+ error: 'Job was cancelled',
238
+ };
239
+ }
240
+
241
+ // Handle other errors
242
+ const errorMessage = error instanceof Error ? error.message : String(error);
243
+
244
+ await this.jobManager.fail(jobId, errorMessage);
245
+
246
+ await this.eventAppender.append({
247
+ jobId,
248
+ type: 'failed',
249
+ payload: { error: errorMessage, timestamp: new Date().toISOString() },
250
+ });
251
+
252
+ const failedJob = await this.jobManager.get(jobId);
253
+
254
+ return {
255
+ job: failedJob ?? job,
256
+ error: errorMessage,
257
+ };
258
+ }
259
+ }
260
+ }
@@ -0,0 +1,252 @@
1
+ // ABOUTME: JobWorkerService for receiving dispatches from the coordinator
2
+ // ABOUTME: Handles job execution and sends callbacks through relay or direct HTTP
3
+
4
+ import type { JobManager, EventAppender, JobHandler } from './types.js';
5
+ import { createJobContext } from './job-context.js';
6
+
7
+ /**
8
+ * Dispatch payload received from the coordinator.
9
+ */
10
+ export interface DispatchPayload {
11
+ jobId: string;
12
+ type: string;
13
+ attempt: number;
14
+ input: unknown;
15
+ traceId: string;
16
+ accountId: string;
17
+ }
18
+
19
+ /**
20
+ * Callback payload sent back to the coordinator.
21
+ */
22
+ export interface CallbackPayload {
23
+ attempt: number;
24
+ success: boolean;
25
+ output?: unknown;
26
+ error?: string;
27
+ }
28
+
29
+ /**
30
+ * Options for creating a JobWorker.
31
+ */
32
+ export interface JobWorkerOptions {
33
+ /** Shared secret for authenticating dispatches */
34
+ authSecret: string;
35
+
36
+ /** JobManager instance for job lifecycle */
37
+ jobManager: JobManager;
38
+
39
+ /** EventAppender instance for event streaming */
40
+ eventAppender: EventAppender;
41
+
42
+ /** Map of job types to handlers */
43
+ handlers: Record<string, JobHandler<unknown, unknown>>;
44
+
45
+ /** Coordinator callback URL (or relay URL) */
46
+ coordinatorCallbackUrl: string;
47
+
48
+ /** Whether to route callbacks through relay */
49
+ callbackViaRelay?: boolean;
50
+
51
+ /** Relay client ID (if callbackViaRelay is true) */
52
+ relayClientId?: string;
53
+
54
+ /** Optional: Fetch function for callbacks (for CF Worker compatibility) */
55
+ fetch?: typeof globalThis.fetch;
56
+ }
57
+
58
+ /**
59
+ * Result of handling a dispatch request.
60
+ */
61
+ export interface DispatchResult {
62
+ accepted: boolean;
63
+ error?: string;
64
+ }
65
+
66
+ /**
67
+ * JobWorkerService interface for receiving coordinated job dispatches.
68
+ */
69
+ export interface JobWorkerService {
70
+ /**
71
+ * Verify authorization header from coordinator.
72
+ */
73
+ verifyAuth(authHeader: string | undefined): boolean;
74
+
75
+ /**
76
+ * Handle a dispatch request from the coordinator.
77
+ * Returns immediately after accepting, processes job in background.
78
+ */
79
+ handleDispatch(
80
+ payload: DispatchPayload,
81
+ waitUntil?: (promise: Promise<unknown>) => void
82
+ ): Promise<DispatchResult>;
83
+
84
+ /**
85
+ * Get registered job types.
86
+ */
87
+ getJobTypes(): string[];
88
+ }
89
+
90
+ /**
91
+ * Creates a JobWorkerService instance for receiving coordinated dispatches.
92
+ */
93
+ export function createJobWorkerService(options: JobWorkerOptions): JobWorkerService {
94
+ const {
95
+ authSecret,
96
+ jobManager,
97
+ eventAppender,
98
+ handlers,
99
+ coordinatorCallbackUrl,
100
+ callbackViaRelay = false,
101
+ relayClientId,
102
+ fetch: fetchFn = globalThis.fetch,
103
+ } = options;
104
+
105
+ const jobTypes = Object.keys(handlers);
106
+
107
+ async function sendCallback(
108
+ jobId: string,
109
+ payload: CallbackPayload
110
+ ): Promise<boolean> {
111
+ try {
112
+ let url: string;
113
+ const headers: Record<string, string> = {
114
+ 'Content-Type': 'application/json',
115
+ 'X-Internal-Auth': authSecret,
116
+ };
117
+
118
+ if (callbackViaRelay && relayClientId) {
119
+ // Route through relay
120
+ url = `${coordinatorCallbackUrl}/relay-callback/${relayClientId}/${jobId}`;
121
+ headers['X-Callback-Type'] = 'job-result';
122
+ } else {
123
+ // Direct callback to coordinator
124
+ url = `${coordinatorCallbackUrl}/callback/${jobId}`;
125
+ }
126
+
127
+ const response = await fetchFn(url, {
128
+ method: 'POST',
129
+ headers,
130
+ body: JSON.stringify(payload),
131
+ });
132
+
133
+ if (!response.ok) {
134
+ console.error(
135
+ `[job-worker] Callback failed: ${response.status} ${await response.text()}`
136
+ );
137
+ return false;
138
+ }
139
+
140
+ return true;
141
+ } catch (error) {
142
+ console.error('[job-worker] Callback error:', error);
143
+ return false;
144
+ }
145
+ }
146
+
147
+ async function executeJob(dispatch: DispatchPayload): Promise<void> {
148
+ const handler = handlers[dispatch.type];
149
+ if (!handler) {
150
+ await sendCallback(dispatch.jobId, {
151
+ attempt: dispatch.attempt,
152
+ success: false,
153
+ error: `No handler for job type: ${dispatch.type}`,
154
+ });
155
+ return;
156
+ }
157
+
158
+ // Get the job from database
159
+ const job = await jobManager.get(dispatch.jobId);
160
+ if (!job) {
161
+ await sendCallback(dispatch.jobId, {
162
+ attempt: dispatch.attempt,
163
+ success: false,
164
+ error: `Job not found: ${dispatch.jobId}`,
165
+ });
166
+ return;
167
+ }
168
+
169
+ // Create job context
170
+ const abortController = new AbortController();
171
+ const ctx = createJobContext({
172
+ job,
173
+ eventAppender,
174
+ jobManager,
175
+ signal: abortController.signal,
176
+ });
177
+
178
+ try {
179
+ // Execute handler
180
+ const output = await handler(ctx);
181
+
182
+ // Send success callback
183
+ await sendCallback(dispatch.jobId, {
184
+ attempt: dispatch.attempt,
185
+ success: true,
186
+ output,
187
+ });
188
+ } catch (error) {
189
+ // Send failure callback
190
+ const errorMessage = error instanceof Error ? error.message : String(error);
191
+ await sendCallback(dispatch.jobId, {
192
+ attempt: dispatch.attempt,
193
+ success: false,
194
+ error: errorMessage,
195
+ });
196
+ }
197
+ }
198
+
199
+ return {
200
+ verifyAuth(authHeader: string | undefined): boolean {
201
+ return authHeader === authSecret;
202
+ },
203
+
204
+ async handleDispatch(
205
+ payload: DispatchPayload,
206
+ waitUntil?: (promise: Promise<unknown>) => void
207
+ ): Promise<DispatchResult> {
208
+ // Validate payload
209
+ if (!payload.jobId || !payload.type) {
210
+ return { accepted: false, error: 'Invalid payload: jobId and type required' };
211
+ }
212
+
213
+ // Check handler exists
214
+ const handler = handlers[payload.type];
215
+ if (!handler) {
216
+ return { accepted: false, error: `No handler for job type: ${payload.type}` };
217
+ }
218
+
219
+ // Execute in background
220
+ const execution = executeJob(payload);
221
+
222
+ if (waitUntil) {
223
+ // CF Workers: use waitUntil to keep worker alive
224
+ waitUntil(execution);
225
+ } else {
226
+ // Node.js: fire and forget (or await if needed)
227
+ execution.catch((err) => {
228
+ console.error('[job-worker] Execution error:', err);
229
+ });
230
+ }
231
+
232
+ return { accepted: true };
233
+ },
234
+
235
+ getJobTypes(): string[] {
236
+ return jobTypes;
237
+ },
238
+ };
239
+ }
240
+
241
+ /**
242
+ * Error thrown by JobWorker operations.
243
+ */
244
+ export class JobWorkerError extends Error {
245
+ constructor(
246
+ message: string,
247
+ public readonly code: string
248
+ ) {
249
+ super(message);
250
+ this.name = 'JobWorkerError';
251
+ }
252
+ }