@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.
- package/dist/artifact-cleanup.d.ts +30 -0
- package/dist/artifact-cleanup.d.ts.map +1 -0
- package/dist/artifact-cleanup.js +44 -0
- package/dist/artifact-cleanup.js.map +1 -0
- package/dist/artifact-manager.d.ts +41 -0
- package/dist/artifact-manager.d.ts.map +1 -0
- package/dist/artifact-manager.js +254 -0
- package/dist/artifact-manager.js.map +1 -0
- package/dist/artifact-materializer.d.ts +21 -0
- package/dist/artifact-materializer.d.ts.map +1 -0
- package/dist/artifact-materializer.js +33 -0
- package/dist/artifact-materializer.js.map +1 -0
- package/dist/artifact-router.d.ts +131 -0
- package/dist/artifact-router.d.ts.map +1 -0
- package/dist/artifact-router.js +4 -0
- package/dist/artifact-router.js.map +1 -0
- package/dist/default-artifact-router.d.ts +26 -0
- package/dist/default-artifact-router.d.ts.map +1 -0
- package/dist/default-artifact-router.js +125 -0
- package/dist/default-artifact-router.js.map +1 -0
- package/dist/event-appender.d.ts +23 -0
- package/dist/event-appender.d.ts.map +1 -0
- package/dist/event-appender.js +120 -0
- package/dist/event-appender.js.map +1 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +28 -0
- package/dist/index.js.map +1 -0
- package/dist/job-context.d.ts +58 -0
- package/dist/job-context.d.ts.map +1 -0
- package/dist/job-context.js +133 -0
- package/dist/job-context.js.map +1 -0
- package/dist/job-manager.d.ts +23 -0
- package/dist/job-manager.d.ts.map +1 -0
- package/dist/job-manager.js +145 -0
- package/dist/job-manager.js.map +1 -0
- package/dist/job-runner.d.ts +32 -0
- package/dist/job-runner.d.ts.map +1 -0
- package/dist/job-runner.js +187 -0
- package/dist/job-runner.js.map +1 -0
- package/dist/job-worker.d.ts +79 -0
- package/dist/job-worker.d.ts.map +1 -0
- package/dist/job-worker.js +134 -0
- package/dist/job-worker.js.map +1 -0
- package/dist/mind-worker.d.ts +63 -0
- package/dist/mind-worker.d.ts.map +1 -0
- package/dist/mind-worker.js +99 -0
- package/dist/mind-worker.js.map +1 -0
- package/dist/schema.d.ts +1143 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/schema.js +225 -0
- package/dist/schema.js.map +1 -0
- package/dist/types.d.ts +434 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +24 -0
- package/dist/types.js.map +1 -0
- package/drizzle/0001_create_jobs_tables.sql +70 -0
- package/drizzle/0002_coordinator_tables.sql +78 -0
- package/drizzle/0003_artifacts.sql +24 -0
- package/drizzle/0004_kernel.sql +28 -0
- package/drizzle/0005_artifact_routing.sql +29 -0
- package/package.json +48 -0
- package/src/artifact-cleanup.ts +85 -0
- package/src/artifact-manager.ts +346 -0
- package/src/artifact-materializer.ts +64 -0
- package/src/artifact-router.ts +151 -0
- package/src/default-artifact-router.ts +186 -0
- package/src/event-appender.ts +158 -0
- package/src/index.ts +136 -0
- package/src/job-context.ts +195 -0
- package/src/job-manager.ts +179 -0
- package/src/job-runner.ts +260 -0
- package/src/job-worker.ts +252 -0
- package/src/mind-worker.ts +152 -0
- package/src/schema.ts +290 -0
- 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
|
+
}
|