streamby-core 0.38.0 → 0.40.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +64 -0
- package/README.md +108 -1
- package/dist/adapters/database/connectionManager.js +55 -0
- package/dist/mcp/tools.js +41 -0
- package/dist/middleware/createRouter.js +11 -1
- package/dist/middleware/routes/ai.js +3 -3
- package/dist/middleware/routes/deliverable.d.ts +4 -2
- package/dist/middleware/routes/deliverable.js +16 -7
- package/dist/middleware/routes/export.js +16 -0
- package/dist/middleware/routes/media.js +7 -6
- package/dist/middleware/routes/project.js +36 -10
- package/dist/middleware/routes/renderFarm.js +4 -77
- package/dist/middleware/routes/storage.js +13 -27
- package/dist/middleware/routes/worker.d.ts +7 -0
- package/dist/middleware/routes/worker.js +125 -0
- package/dist/middleware/routes/workerCallback.d.ts +5 -0
- package/dist/middleware/routes/workerCallback.js +31 -0
- package/dist/services/aiProcessor.js +11 -11
- package/dist/services/apiTokenPermissions.js +8 -7
- package/dist/services/executor.js +10 -10
- package/dist/services/jobQueue.d.ts +6 -4
- package/dist/services/jobQueue.js +28 -16
- package/dist/services/jobStore.d.ts +27 -0
- package/dist/services/jobStore.js +177 -0
- package/dist/services/mediaProcessor.js +16 -16
- package/dist/services/vfxProcessor.js +12 -12
- package/dist/services/workerJobs.d.ts +73 -0
- package/dist/services/workerJobs.js +459 -0
- package/dist/services/workers.d.ts +78 -0
- package/dist/services/workers.js +179 -0
- package/dist/types/index.d.ts +52 -2
- package/dist/utils/assetCategories.d.ts +4 -0
- package/dist/utils/assetCategories.js +21 -0
- package/dist/utils/builtinAccess.d.ts +1 -1
- package/dist/utils/builtinAccess.js +4 -3
- package/dist/utils/privateAddress.d.ts +10 -0
- package/dist/utils/privateAddress.js +85 -0
- package/dist/utils/sanitize.d.ts +1 -0
- package/dist/utils/sanitize.js +14 -0
- package/package.json +1 -1
|
@@ -0,0 +1,459 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.publicJob = exports.WORKER_CALLBACK_PATH = void 0;
|
|
7
|
+
exports.enqueueWorkerJob = enqueueWorkerJob;
|
|
8
|
+
exports.enqueueAutomaticJobs = enqueueAutomaticJobs;
|
|
9
|
+
exports.retryWorkerJob = retryWorkerJob;
|
|
10
|
+
exports.dispatchJob = dispatchJob;
|
|
11
|
+
exports.startWorkerSweeper = startWorkerSweeper;
|
|
12
|
+
exports.authorizeCallback = authorizeCallback;
|
|
13
|
+
exports.reportProgress = reportProgress;
|
|
14
|
+
exports.resultUploadUrl = resultUploadUrl;
|
|
15
|
+
exports.completeJob = completeJob;
|
|
16
|
+
exports.failWorkerJob = failWorkerJob;
|
|
17
|
+
const crypto_1 = __importDefault(require("crypto"));
|
|
18
|
+
const mongodb_1 = require("mongodb");
|
|
19
|
+
const manager_1 = require("../models/manager");
|
|
20
|
+
const storage_1 = require("../providers/storage");
|
|
21
|
+
const nosqlDb_1 = require("../utils/nosqlDb");
|
|
22
|
+
const assetCategories_1 = require("../utils/assetCategories");
|
|
23
|
+
const connectionResolver_1 = require("./connectionResolver");
|
|
24
|
+
const assetVersions_1 = require("./assetVersions");
|
|
25
|
+
const jobQueue_1 = require("./jobQueue");
|
|
26
|
+
const jobStore_1 = require("./jobStore");
|
|
27
|
+
const workers_1 = require("./workers");
|
|
28
|
+
// A worker's job, from start to end (the README has the contract a worker follows):
|
|
29
|
+
//
|
|
30
|
+
// 1. Asked for (a person, an agent, or a deliverable marked): it's kept as pending.
|
|
31
|
+
// 2. Sent to its worker: a POST to its endpoint with what to process, a short-lived URL to
|
|
32
|
+
// download the asset, and where and how to call back (a token only this attempt has).
|
|
33
|
+
// 3. The worker calls back: asks where to upload its result, reports progress, and ends it
|
|
34
|
+
// (complete, with a report; or fail, with its error).
|
|
35
|
+
// 4. Completed: the result is kept as a new version of the asset, or as a new asset derived
|
|
36
|
+
// from it (the original is never touched), with the worker's report.
|
|
37
|
+
//
|
|
38
|
+
// A job that can't be sent, or that its worker doesn't finish in time, is sent again (up to
|
|
39
|
+
// workerOptions.maxAttempts); then it fails, with its error, and can be retried by hand.
|
|
40
|
+
exports.WORKER_CALLBACK_PATH = '/worker-jobs';
|
|
41
|
+
const DEFAULT_MAX_ATTEMPTS = 3;
|
|
42
|
+
const RETRY_BASE_MS = 30000;
|
|
43
|
+
const DOWNLOAD_TTL_SECONDS = 3600;
|
|
44
|
+
const UPLOAD_TTL_SECONDS = 3600;
|
|
45
|
+
const filesCollection = () => (0, nosqlDb_1.nosqlDb)()?.collection('storage_files') ?? null;
|
|
46
|
+
const hashOf = (token) => crypto_1.default.createHash('sha256').update(token).digest('hex');
|
|
47
|
+
const extensionOf = (name) => (name.includes('.') ? `.${name.split('.').pop().toLowerCase()}` : '');
|
|
48
|
+
/**
|
|
49
|
+
* As whom a job acts: whoever asked for it, with the platform role and name they had then, so
|
|
50
|
+
* a host's canUseBuiltin decides on sending it as it did on asking for it.
|
|
51
|
+
*/
|
|
52
|
+
const authOf = (job) => ({
|
|
53
|
+
userId: job.userId,
|
|
54
|
+
username: job.payload?.requester?.username ?? '',
|
|
55
|
+
role: job.payload?.requester?.role ?? 'viewer',
|
|
56
|
+
});
|
|
57
|
+
const findProject = (projectId) => (0, manager_1.getModel)('projects').findOne({ _id: projectId });
|
|
58
|
+
// The storage an asset is in: its storage connection's, or the default one.
|
|
59
|
+
async function fileAdapter(project, fileDoc, auth, config) {
|
|
60
|
+
const connId = fileDoc.storageConnectionId;
|
|
61
|
+
if (connId && (project.storageConnections ?? []).some((c) => c.id === connId)) {
|
|
62
|
+
const resolved = await (0, connectionResolver_1.resolveStorageAdapter)(project, connId, config, auth);
|
|
63
|
+
if ('error' in resolved)
|
|
64
|
+
throw new workers_1.WorkerError(resolved.error, resolved.status);
|
|
65
|
+
return resolved;
|
|
66
|
+
}
|
|
67
|
+
return config.adapter || (0, storage_1.createStorageProvider)(config.storageProviders);
|
|
68
|
+
}
|
|
69
|
+
/** Asks a worker to process an asset, as `auth`: the job, pending, to be sent right away. */
|
|
70
|
+
async function enqueueWorkerJob(project, request, auth, config, callbackBase) {
|
|
71
|
+
const projectId = String(project._id ?? project.id);
|
|
72
|
+
if (typeof request.assetId !== 'string' || !request.assetId)
|
|
73
|
+
throw new workers_1.WorkerError('assetId is required');
|
|
74
|
+
const ref = request.worker;
|
|
75
|
+
if (!ref || typeof ref.id !== 'string' || typeof ref.source !== 'string') {
|
|
76
|
+
throw new workers_1.WorkerError("worker is required: { source: 'builtin' | 'project', id }");
|
|
77
|
+
}
|
|
78
|
+
const worker = await (0, workers_1.resolveWorker)(project, { source: ref.source, id: ref.id }, auth, config);
|
|
79
|
+
if (typeof request.kind !== 'string' || !worker.kinds.includes(request.kind)) {
|
|
80
|
+
throw new workers_1.WorkerError(`kind must be one of the worker's: ${worker.kinds.join(', ')}`);
|
|
81
|
+
}
|
|
82
|
+
const resultMode = (request.resultMode ?? 'derived');
|
|
83
|
+
if (resultMode !== 'derived' && resultMode !== 'version')
|
|
84
|
+
throw new workers_1.WorkerError("resultMode must be 'derived' or 'version'");
|
|
85
|
+
const profile = request.profile ?? {};
|
|
86
|
+
if (typeof profile !== 'object' || Array.isArray(profile) || profile === null)
|
|
87
|
+
throw new workers_1.WorkerError('profile must be an object');
|
|
88
|
+
const files = filesCollection();
|
|
89
|
+
if (!files)
|
|
90
|
+
throw new workers_1.WorkerError('Database not available', 500);
|
|
91
|
+
const fileDoc = await files.findOne({ fileId: request.assetId, projectId });
|
|
92
|
+
if (!fileDoc)
|
|
93
|
+
throw new workers_1.WorkerError('Asset not found', 404);
|
|
94
|
+
if (!worker.categories.includes(fileDoc.category)) {
|
|
95
|
+
throw new workers_1.WorkerError(`This worker takes ${worker.categories.join(', ')}, not ${fileDoc.category}`);
|
|
96
|
+
}
|
|
97
|
+
const versionId = typeof request.versionId === 'string' && request.versionId ? request.versionId : null;
|
|
98
|
+
if (versionId && !(await (0, assetVersions_1.findVersion)(projectId, request.assetId, versionId)))
|
|
99
|
+
throw new workers_1.WorkerError('Version not found', 404);
|
|
100
|
+
if (resultMode === 'version' && !(await fileAdapter(project, fileDoc, auth, config)).copyFile) {
|
|
101
|
+
throw new workers_1.WorkerError("This asset's storage can't keep versions: ask for a derived result", 400);
|
|
102
|
+
}
|
|
103
|
+
const requester = { role: auth.role, username: auth.username };
|
|
104
|
+
const job = await (0, jobQueue_1.createJob)('process', auth.userId, projectId, { callbackBase, requester }, {
|
|
105
|
+
assetId: request.assetId,
|
|
106
|
+
versionId,
|
|
107
|
+
worker: worker.ref,
|
|
108
|
+
kind: request.kind,
|
|
109
|
+
profile: profile,
|
|
110
|
+
resultMode,
|
|
111
|
+
attempts: 0,
|
|
112
|
+
maxAttempts: config.workerOptions?.maxAttempts ?? DEFAULT_MAX_ATTEMPTS,
|
|
113
|
+
nextAttemptAt: new Date(),
|
|
114
|
+
});
|
|
115
|
+
kick(job.jobId, config);
|
|
116
|
+
return job;
|
|
117
|
+
}
|
|
118
|
+
/** Runs the workers set to process, by themselves, an asset of their categories marked as a deliverable. */
|
|
119
|
+
async function enqueueAutomaticJobs(project, assetId, versionId, auth, config, callbackBase) {
|
|
120
|
+
const projectId = String(project._id ?? project.id);
|
|
121
|
+
const fileDoc = await filesCollection()?.findOne({ fileId: assetId, projectId });
|
|
122
|
+
if (!fileDoc)
|
|
123
|
+
return [];
|
|
124
|
+
const jobs = [];
|
|
125
|
+
for (const worker of await (0, workers_1.availableWorkers)(project, auth, config)) {
|
|
126
|
+
if (!worker.autoKind || !worker.categories.includes(fileDoc.category))
|
|
127
|
+
continue;
|
|
128
|
+
try {
|
|
129
|
+
jobs.push(await enqueueWorkerJob(project, { assetId, versionId, worker: { source: worker.source, id: worker.id }, kind: worker.autoKind }, auth, config, callbackBase));
|
|
130
|
+
}
|
|
131
|
+
catch (err) {
|
|
132
|
+
console.error(`❌ StreamBy workers: couldn't start ${worker.name} for asset ${assetId}:`, err instanceof Error ? err.message : err);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return jobs;
|
|
136
|
+
}
|
|
137
|
+
/** A failed worker job, back in line. */
|
|
138
|
+
async function retryWorkerJob(jobId, config) {
|
|
139
|
+
const job = await (0, jobStore_1.jobStore)().claim(jobId, ['failed'], {
|
|
140
|
+
status: 'pending', stage: 'queued', progress: 0, error: null, attempts: 0,
|
|
141
|
+
nextAttemptAt: new Date(), leaseUntil: null, callbackHash: null, upload: null, updatedAt: new Date(),
|
|
142
|
+
});
|
|
143
|
+
if (job) {
|
|
144
|
+
(0, jobQueue_1.notify)(job);
|
|
145
|
+
kick(job.jobId, config);
|
|
146
|
+
}
|
|
147
|
+
return job;
|
|
148
|
+
}
|
|
149
|
+
// ── Sending a job to its worker ──
|
|
150
|
+
const kick = (jobId, config) => {
|
|
151
|
+
setImmediate(() => { dispatchJob(jobId, config).catch(err => console.error('❌ StreamBy workers: dispatch failed:', err?.message ?? err)); });
|
|
152
|
+
};
|
|
153
|
+
const retryDelay = (attempts) => RETRY_BASE_MS * 2 ** Math.max(0, attempts - 1);
|
|
154
|
+
/** Ends an attempt that went wrong: sent again later while attempts are left, or failed. */
|
|
155
|
+
async function attemptFailed(job, error) {
|
|
156
|
+
const attempts = job.attempts ?? 1;
|
|
157
|
+
const left = attempts < (job.maxAttempts ?? DEFAULT_MAX_ATTEMPTS);
|
|
158
|
+
const updated = await (0, jobStore_1.jobStore)().update(job.jobId, left
|
|
159
|
+
? { status: 'pending', stage: 'retrying', error, nextAttemptAt: new Date(Date.now() + retryDelay(attempts)), leaseUntil: null, callbackHash: null, upload: null, updatedAt: new Date() }
|
|
160
|
+
: { status: 'failed', stage: 'failed', error, leaseUntil: null, callbackHash: null, upload: null, updatedAt: new Date() });
|
|
161
|
+
if (updated)
|
|
162
|
+
(0, jobQueue_1.notify)(updated);
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Sends a pending job (or one its worker didn't finish in time) to its worker. Of several
|
|
166
|
+
* instances, only the one that claims it does.
|
|
167
|
+
*/
|
|
168
|
+
async function dispatchJob(jobId, config) {
|
|
169
|
+
const now = new Date();
|
|
170
|
+
const current = await (0, jobStore_1.jobStore)().get(jobId);
|
|
171
|
+
if (!current?.worker)
|
|
172
|
+
return;
|
|
173
|
+
// Its worker had it and the time's up: sent again, or failed.
|
|
174
|
+
if (current.status === 'processing') {
|
|
175
|
+
if (!current.leaseUntil || new Date(current.leaseUntil) > now)
|
|
176
|
+
return;
|
|
177
|
+
const timedOut = await (0, jobStore_1.jobStore)().claim(jobId, ['processing'], { leaseUntil: null, updatedAt: now }, { leaseEndedBy: now });
|
|
178
|
+
if (timedOut)
|
|
179
|
+
await attemptFailed(timedOut, "The worker didn't finish in time");
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
if (current.status !== 'pending' || (current.nextAttemptAt && new Date(current.nextAttemptAt) > now))
|
|
183
|
+
return;
|
|
184
|
+
const project = await findProject(current.projectId);
|
|
185
|
+
let worker;
|
|
186
|
+
try {
|
|
187
|
+
if (!project)
|
|
188
|
+
throw new workers_1.WorkerError('Project not found', 404);
|
|
189
|
+
worker = await (0, workers_1.resolveWorker)(project, current.worker, authOf(current), config);
|
|
190
|
+
}
|
|
191
|
+
catch (err) {
|
|
192
|
+
const failed = await (0, jobStore_1.jobStore)().claim(jobId, ['pending'], {
|
|
193
|
+
status: 'failed', stage: 'failed', error: err instanceof Error ? err.message : String(err), updatedAt: now,
|
|
194
|
+
});
|
|
195
|
+
if (failed)
|
|
196
|
+
(0, jobQueue_1.notify)(failed);
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
const token = crypto_1.default.randomBytes(32).toString('base64url');
|
|
200
|
+
const job = await (0, jobStore_1.jobStore)().claim(jobId, ['pending'], {
|
|
201
|
+
status: 'processing', stage: 'sent', attempts: (current.attempts ?? 0) + 1, callbackHash: hashOf(token),
|
|
202
|
+
leaseUntil: new Date(now.getTime() + worker.timeoutSeconds * 1000), nextAttemptAt: null, updatedAt: now,
|
|
203
|
+
});
|
|
204
|
+
if (!job)
|
|
205
|
+
return; // another instance took it
|
|
206
|
+
(0, jobQueue_1.notify)(job);
|
|
207
|
+
try {
|
|
208
|
+
const files = filesCollection();
|
|
209
|
+
const fileDoc = files && await files.findOne({ fileId: job.assetId, projectId: job.projectId });
|
|
210
|
+
if (!fileDoc)
|
|
211
|
+
throw new workers_1.WorkerError('Asset not found', 404);
|
|
212
|
+
const version = job.versionId ? await (0, assetVersions_1.findVersion)(job.projectId, job.assetId, job.versionId) : null;
|
|
213
|
+
if (job.versionId && !version)
|
|
214
|
+
throw new workers_1.WorkerError('Version not found', 404);
|
|
215
|
+
const adapter = await fileAdapter(project, fileDoc, authOf(job), config);
|
|
216
|
+
if (!adapter.getPresignedGetUrl)
|
|
217
|
+
throw new workers_1.WorkerError("This asset's storage can't give download URLs");
|
|
218
|
+
const fileName = version?.fileName ?? fileDoc.displayName;
|
|
219
|
+
const download = await adapter.getPresignedGetUrl(version?.storageKey ?? fileDoc.storageKey, DOWNLOAD_TTL_SECONDS, fileName);
|
|
220
|
+
const callbackBase = config.workerOptions?.callbackBaseUrl ?? job.payload?.callbackBase;
|
|
221
|
+
// A project's worker: its name may resolve, now, to the server's own network.
|
|
222
|
+
if (worker.ref.source === 'project')
|
|
223
|
+
await (0, workers_1.assertPublicEndpoint)(worker.endpoint, config);
|
|
224
|
+
const response = await fetch(worker.endpoint, {
|
|
225
|
+
method: 'POST',
|
|
226
|
+
redirect: 'error',
|
|
227
|
+
headers: { 'Content-Type': 'application/json', ...(worker.credential ? { Authorization: `Bearer ${worker.credential}` } : {}) },
|
|
228
|
+
body: JSON.stringify({
|
|
229
|
+
jobId: job.jobId,
|
|
230
|
+
kind: job.kind,
|
|
231
|
+
profile: job.profile ?? {},
|
|
232
|
+
attempt: job.attempts,
|
|
233
|
+
asset: {
|
|
234
|
+
id: job.assetId, versionId: version?.versionId ?? null, fileName,
|
|
235
|
+
contentType: version?.contentType ?? fileDoc.contentType ?? null, category: fileDoc.category,
|
|
236
|
+
},
|
|
237
|
+
download: { url: download, expiresAt: new Date(now.getTime() + DOWNLOAD_TTL_SECONDS * 1000).toISOString() },
|
|
238
|
+
callback: { url: `${String(callbackBase).replace(/\/$/, '')}${exports.WORKER_CALLBACK_PATH}/${job.jobId}`, token },
|
|
239
|
+
deadline: job.leaseUntil,
|
|
240
|
+
}),
|
|
241
|
+
signal: AbortSignal.timeout(30000),
|
|
242
|
+
});
|
|
243
|
+
if (!response.ok) {
|
|
244
|
+
// Its answer stays in the server's log: an endpoint's status isn't the user's to see.
|
|
245
|
+
console.error(`❌ StreamBy workers: ${worker.ref.name} answered ${response.status} to job ${job.jobId}`);
|
|
246
|
+
throw new Error('The worker refused the job');
|
|
247
|
+
}
|
|
248
|
+
const accepted = await (0, jobStore_1.jobStore)().claim(jobId, ['processing'], { stage: 'accepted', updatedAt: new Date() }, { callbackHash: hashOf(token) });
|
|
249
|
+
if (accepted)
|
|
250
|
+
(0, jobQueue_1.notify)(accepted);
|
|
251
|
+
}
|
|
252
|
+
catch (err) {
|
|
253
|
+
const message = err instanceof Error ? (err.name === 'TimeoutError' ? "The worker didn't answer" : err.message) : String(err);
|
|
254
|
+
// Only this attempt's own: the worker may have called back already.
|
|
255
|
+
const still = await (0, jobStore_1.jobStore)().get(jobId);
|
|
256
|
+
if (still?.status !== 'processing' || still.callbackHash !== hashOf(token))
|
|
257
|
+
return;
|
|
258
|
+
if (err instanceof workers_1.PrivateEndpointError) {
|
|
259
|
+
// Not sent, and never will be: no attempt is left for an endpoint at a private address.
|
|
260
|
+
const failed = await (0, jobStore_1.jobStore)().update(jobId, {
|
|
261
|
+
status: 'failed', stage: 'failed', error: message, leaseUntil: null, callbackHash: null, upload: null, updatedAt: new Date(),
|
|
262
|
+
});
|
|
263
|
+
if (failed)
|
|
264
|
+
(0, jobQueue_1.notify)(failed);
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
await attemptFailed(still, message);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
/** Looks, now and then, for jobs to send (again). Every instance does; claiming keeps them from doubling. */
|
|
271
|
+
function startWorkerSweeper(config) {
|
|
272
|
+
const interval = config.workerOptions?.sweepIntervalMs ?? 30000;
|
|
273
|
+
if (!interval)
|
|
274
|
+
return () => { };
|
|
275
|
+
const timer = setInterval(async () => {
|
|
276
|
+
try {
|
|
277
|
+
for (const job of await (0, jobStore_1.jobStore)().due(new Date(), 20))
|
|
278
|
+
await dispatchJob(job.jobId, config);
|
|
279
|
+
}
|
|
280
|
+
catch (err) {
|
|
281
|
+
console.error('❌ StreamBy workers: sweep failed:', err instanceof Error ? err.message : err);
|
|
282
|
+
}
|
|
283
|
+
}, interval);
|
|
284
|
+
timer.unref?.();
|
|
285
|
+
return () => clearInterval(timer);
|
|
286
|
+
}
|
|
287
|
+
// ── The worker's callbacks ──
|
|
288
|
+
/** The job a callback is about, if its token is this attempt's and the job is in progress. */
|
|
289
|
+
async function authorizeCallback(jobId, authorization) {
|
|
290
|
+
const token = authorization?.match(/^Bearer\s+(.+)$/i)?.[1]?.trim();
|
|
291
|
+
if (!token)
|
|
292
|
+
throw new workers_1.WorkerError('A callback needs its job token (Authorization: Bearer ...)', 401);
|
|
293
|
+
const job = await (0, jobQueue_1.getJob)(jobId);
|
|
294
|
+
const expected = job?.callbackHash ? Buffer.from(job.callbackHash, 'hex') : null;
|
|
295
|
+
const given = Buffer.from(hashOf(token), 'hex');
|
|
296
|
+
if (!job || !expected || expected.length !== given.length || !crypto_1.default.timingSafeEqual(expected, given)) {
|
|
297
|
+
throw new workers_1.WorkerError('Invalid job token', 401);
|
|
298
|
+
}
|
|
299
|
+
if (job.status !== 'processing')
|
|
300
|
+
throw new workers_1.WorkerError(`The job is ${job.status}`, 409);
|
|
301
|
+
return job;
|
|
302
|
+
}
|
|
303
|
+
async function reportProgress(job, body) {
|
|
304
|
+
const patch = { updatedAt: new Date() };
|
|
305
|
+
if (body?.progress !== undefined) {
|
|
306
|
+
const progress = Number(body.progress);
|
|
307
|
+
if (!Number.isFinite(progress) || progress < 0 || progress > 100)
|
|
308
|
+
throw new workers_1.WorkerError('progress must be from 0 to 100');
|
|
309
|
+
patch.progress = Math.round(progress);
|
|
310
|
+
}
|
|
311
|
+
if (body?.stage !== undefined) {
|
|
312
|
+
if (typeof body.stage !== 'string' || !body.stage.trim() || body.stage.length > 60)
|
|
313
|
+
throw new workers_1.WorkerError('stage must be a short text');
|
|
314
|
+
patch.stage = body.stage.trim();
|
|
315
|
+
}
|
|
316
|
+
const updated = await (0, jobStore_1.jobStore)().claim(job.jobId, ['processing'], patch);
|
|
317
|
+
if (updated)
|
|
318
|
+
(0, jobQueue_1.notify)(updated);
|
|
319
|
+
return updated;
|
|
320
|
+
}
|
|
321
|
+
/** Where the worker uploads its result: a key of its own until the job completes. */
|
|
322
|
+
async function resultUploadUrl(job, body, config) {
|
|
323
|
+
const fileName = typeof body?.fileName === 'string' ? body.fileName.trim() : '';
|
|
324
|
+
const contentType = typeof body?.contentType === 'string' ? body.contentType.trim() : '';
|
|
325
|
+
if (!fileName || !contentType || fileName.includes('/'))
|
|
326
|
+
throw new workers_1.WorkerError('fileName and contentType are required');
|
|
327
|
+
const project = await findProject(job.projectId);
|
|
328
|
+
const fileDoc = await filesCollection()?.findOne({ fileId: job.assetId, projectId: job.projectId });
|
|
329
|
+
if (!project || !fileDoc)
|
|
330
|
+
throw new workers_1.WorkerError('Asset not found', 404);
|
|
331
|
+
if (!(0, assetCategories_1.validateContentType)(contentType, fileName, fileDoc.category)) {
|
|
332
|
+
throw new workers_1.WorkerError(`The result must be of the asset's category, ${fileDoc.category}`);
|
|
333
|
+
}
|
|
334
|
+
const adapter = await fileAdapter(project, fileDoc, authOf(job), config);
|
|
335
|
+
if (!adapter.getPresignedUploadUrl)
|
|
336
|
+
throw new workers_1.WorkerError("This asset's storage can't give upload URLs");
|
|
337
|
+
const storageKey = job.resultMode === 'version'
|
|
338
|
+
? `${String(fileDoc.storageKey).replace(/\.[^./]*$/, '')}.job-${job.jobId}${extensionOf(fileName)}`
|
|
339
|
+
: `${job.projectId}/${fileDoc.category}/${crypto_1.default.randomUUID()}${extensionOf(fileName)}`;
|
|
340
|
+
const url = await adapter.getPresignedUploadUrl(storageKey, contentType, UPLOAD_TTL_SECONDS);
|
|
341
|
+
const updated = await (0, jobStore_1.jobStore)().claim(job.jobId, ['processing'], {
|
|
342
|
+
upload: { storageKey, fileName, contentType }, stage: 'uploading', updatedAt: new Date(),
|
|
343
|
+
});
|
|
344
|
+
if (!updated)
|
|
345
|
+
throw new workers_1.WorkerError('The job is no longer in progress', 409);
|
|
346
|
+
(0, jobQueue_1.notify)(updated);
|
|
347
|
+
return {
|
|
348
|
+
url, method: 'PUT', headers: { 'Content-Type': contentType },
|
|
349
|
+
expiresAt: new Date(Date.now() + UPLOAD_TTL_SECONDS * 1000).toISOString(),
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
/** The worker's result, kept: a new version of the asset, or a new asset derived from it. */
|
|
353
|
+
async function completeJob(job, body, config) {
|
|
354
|
+
if (!job.upload)
|
|
355
|
+
throw new workers_1.WorkerError('Upload the result first (upload-url)', 409);
|
|
356
|
+
const report = body?.report ?? null;
|
|
357
|
+
if (report !== null && (typeof report !== 'object' || Array.isArray(report)))
|
|
358
|
+
throw new workers_1.WorkerError('report must be an object');
|
|
359
|
+
if (JSON.stringify(report ?? {}).length > 64000)
|
|
360
|
+
throw new workers_1.WorkerError('report is too large');
|
|
361
|
+
// Claimed first, its token spent: a second callback (or a timeout) can't complete it twice.
|
|
362
|
+
const claimed = await (0, jobStore_1.jobStore)().claim(job.jobId, ['processing'], { stage: 'saving', leaseUntil: null, callbackHash: null, updatedAt: new Date() }, { callbackHash: job.callbackHash ?? '' });
|
|
363
|
+
if (!claimed)
|
|
364
|
+
throw new workers_1.WorkerError('The job is no longer in progress', 409);
|
|
365
|
+
try {
|
|
366
|
+
const result = await keepResult(job, report, config);
|
|
367
|
+
const done = await (0, jobStore_1.jobStore)().update(job.jobId, {
|
|
368
|
+
status: 'completed', stage: 'complete', progress: 100, error: null, report, result, upload: null, updatedAt: new Date(),
|
|
369
|
+
});
|
|
370
|
+
if (done)
|
|
371
|
+
(0, jobQueue_1.notify)(done);
|
|
372
|
+
return done;
|
|
373
|
+
}
|
|
374
|
+
catch (err) {
|
|
375
|
+
const failed = await (0, jobStore_1.jobStore)().update(job.jobId, {
|
|
376
|
+
status: 'failed', stage: 'failed', error: `Its result couldn't be kept: ${err instanceof Error ? err.message : err}`, updatedAt: new Date(),
|
|
377
|
+
});
|
|
378
|
+
if (failed)
|
|
379
|
+
(0, jobQueue_1.notify)(failed);
|
|
380
|
+
throw err;
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
async function keepResult(job, report, config) {
|
|
384
|
+
const project = await findProject(job.projectId);
|
|
385
|
+
const files = filesCollection();
|
|
386
|
+
const fileDoc = files && await files.findOne({ fileId: job.assetId, projectId: job.projectId });
|
|
387
|
+
if (!project || !files || !fileDoc)
|
|
388
|
+
throw new workers_1.WorkerError('Asset not found', 404);
|
|
389
|
+
const adapter = await fileAdapter(project, fileDoc, authOf(job), config);
|
|
390
|
+
const { storageKey, fileName, contentType } = job.upload;
|
|
391
|
+
const meta = { report, jobId: job.jobId, kind: job.kind, worker: job.worker };
|
|
392
|
+
let result;
|
|
393
|
+
if (job.resultMode === 'version') {
|
|
394
|
+
if (!adapter.copyFile)
|
|
395
|
+
throw new workers_1.WorkerError("This asset's storage can't keep versions", 400);
|
|
396
|
+
// The asset keeps its key (and URL): its current version is archived, the result takes its place.
|
|
397
|
+
const version = await (0, assetVersions_1.archiveCurrentVersion)(fileDoc, { fileName, contentType, userId: job.userId }, adapter.copyFile.bind(adapter));
|
|
398
|
+
await adapter.copyFile(storageKey, fileDoc.storageKey);
|
|
399
|
+
if (adapter.deleteFile)
|
|
400
|
+
await adapter.deleteFile(storageKey).catch(() => { });
|
|
401
|
+
await files.updateOne({ fileId: job.assetId, projectId: job.projectId }, { $set: { contentType, updatedAt: new Date() } });
|
|
402
|
+
await (0, assetVersions_1.versionsCollection)()?.updateOne({ versionId: version.versionId }, { $set: meta });
|
|
403
|
+
result = { assetId: job.assetId, versionId: version.versionId, mode: 'version' };
|
|
404
|
+
}
|
|
405
|
+
else {
|
|
406
|
+
// A new asset, linked to the one (and the version) it came from.
|
|
407
|
+
const fileId = crypto_1.default.randomUUID();
|
|
408
|
+
const derived = {
|
|
409
|
+
_id: new mongodb_1.ObjectId(), fileId, projectId: job.projectId,
|
|
410
|
+
...(fileDoc.storageConnectionId ? { storageConnectionId: fileDoc.storageConnectionId } : {}),
|
|
411
|
+
category: fileDoc.category, storageKey, displayName: fileName, contentType,
|
|
412
|
+
uploadedBy: job.userId, createdAt: new Date(), folderId: fileDoc.folderId ?? null,
|
|
413
|
+
derivedFrom: { assetId: job.assetId, versionId: job.versionId ?? null, jobId: job.jobId, kind: job.kind },
|
|
414
|
+
};
|
|
415
|
+
await files.insertOne(derived);
|
|
416
|
+
await (0, assetVersions_1.recordFirstVersion)(derived);
|
|
417
|
+
const [first] = (await (0, assetVersions_1.versionsCollection)()?.find({ assetId: fileId, projectId: job.projectId }).limit(1).toArray()) ?? [];
|
|
418
|
+
if (first)
|
|
419
|
+
await (0, assetVersions_1.versionsCollection)()?.updateOne({ versionId: first.versionId }, { $set: meta });
|
|
420
|
+
result = { assetId: fileId, versionId: first?.versionId ?? '', mode: 'derived' };
|
|
421
|
+
}
|
|
422
|
+
return result;
|
|
423
|
+
}
|
|
424
|
+
/** The worker couldn't do it: sent again if it says it may work later (and attempts are left), or failed. */
|
|
425
|
+
async function failWorkerJob(job, body, config) {
|
|
426
|
+
const error = typeof body?.error === 'string' && body.error.trim() ? body.error.trim().slice(0, 2000) : 'The worker failed';
|
|
427
|
+
const claimed = await (0, jobStore_1.jobStore)().claim(job.jobId, ['processing'], { leaseUntil: null, callbackHash: null, updatedAt: new Date() }, { callbackHash: job.callbackHash ?? '' });
|
|
428
|
+
if (!claimed)
|
|
429
|
+
throw new workers_1.WorkerError('The job is no longer in progress', 409);
|
|
430
|
+
await discardUpload(claimed, config);
|
|
431
|
+
if (body?.retryable === true)
|
|
432
|
+
await attemptFailed(claimed, error);
|
|
433
|
+
else {
|
|
434
|
+
const failed = await (0, jobStore_1.jobStore)().update(job.jobId, { status: 'failed', stage: 'failed', error, callbackHash: null, upload: null, updatedAt: new Date() });
|
|
435
|
+
if (failed)
|
|
436
|
+
(0, jobQueue_1.notify)(failed);
|
|
437
|
+
}
|
|
438
|
+
return (0, jobStore_1.jobStore)().get(job.jobId);
|
|
439
|
+
}
|
|
440
|
+
// A failed attempt's upload, if any, isn't kept.
|
|
441
|
+
async function discardUpload(job, config) {
|
|
442
|
+
if (!job.upload)
|
|
443
|
+
return;
|
|
444
|
+
try {
|
|
445
|
+
const project = await findProject(job.projectId);
|
|
446
|
+
const fileDoc = await filesCollection()?.findOne({ fileId: job.assetId, projectId: job.projectId });
|
|
447
|
+
if (!project || !fileDoc)
|
|
448
|
+
return;
|
|
449
|
+
const adapter = await fileAdapter(project, fileDoc, authOf(job), config);
|
|
450
|
+
await adapter.deleteFile?.(job.upload.storageKey);
|
|
451
|
+
}
|
|
452
|
+
catch { /* a leftover upload only */ }
|
|
453
|
+
}
|
|
454
|
+
/** What's shown of a job: never its callback token's hash, nor its upload's key. */
|
|
455
|
+
const publicJob = ({ callbackHash: _hash, upload: _upload, payload, ...job }) => ({
|
|
456
|
+
...job,
|
|
457
|
+
payload: job.jobType === 'process' ? {} : payload,
|
|
458
|
+
});
|
|
459
|
+
exports.publicJob = publicJob;
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { Auth, StorageCategoryName, StreamByConfig, WorkerRef } from '../types';
|
|
2
|
+
export declare const STORAGE_CATEGORIES: StorageCategoryName[];
|
|
3
|
+
export declare const WORKER_PROVIDERS: readonly ["streamby-worker", "flamenco", "deadline", "rebusfarm", "sheepit", "custom"];
|
|
4
|
+
export declare const DEFAULT_WORKER_TIMEOUT_SECONDS = 3600;
|
|
5
|
+
/** A project's worker, as stored. */
|
|
6
|
+
export interface WorkerConnection {
|
|
7
|
+
id: string;
|
|
8
|
+
projectId: string;
|
|
9
|
+
name: string;
|
|
10
|
+
provider: string;
|
|
11
|
+
apiUrl: string;
|
|
12
|
+
encryptedCredential?: string;
|
|
13
|
+
kinds?: string[];
|
|
14
|
+
categories?: StorageCategoryName[];
|
|
15
|
+
autoKind?: string | null;
|
|
16
|
+
timeoutSeconds?: number;
|
|
17
|
+
description?: string;
|
|
18
|
+
createdBy?: string;
|
|
19
|
+
createdAt: Date;
|
|
20
|
+
}
|
|
21
|
+
/** What a project's member sees of a worker: never its credential. */
|
|
22
|
+
export interface WorkerSummary {
|
|
23
|
+
source: WorkerRef['source'];
|
|
24
|
+
id: string;
|
|
25
|
+
name: string;
|
|
26
|
+
provider: string;
|
|
27
|
+
kinds: string[];
|
|
28
|
+
categories: StorageCategoryName[];
|
|
29
|
+
autoKind: string | null;
|
|
30
|
+
description: string | null;
|
|
31
|
+
hasCredential: boolean;
|
|
32
|
+
createdBy: string | null;
|
|
33
|
+
}
|
|
34
|
+
/** A worker ready to be sent a job. */
|
|
35
|
+
export interface ResolvedWorker {
|
|
36
|
+
ref: WorkerRef;
|
|
37
|
+
endpoint: string;
|
|
38
|
+
credential: string | null;
|
|
39
|
+
kinds: string[];
|
|
40
|
+
categories: StorageCategoryName[];
|
|
41
|
+
autoKind: string | null;
|
|
42
|
+
timeoutSeconds: number;
|
|
43
|
+
}
|
|
44
|
+
export declare class WorkerError extends Error {
|
|
45
|
+
status: number;
|
|
46
|
+
constructor(message: string, status?: number);
|
|
47
|
+
}
|
|
48
|
+
/** A worker endpoint at a local or private address: never sent to, whatever the attempt. */
|
|
49
|
+
export declare class PrivateEndpointError extends WorkerError {
|
|
50
|
+
}
|
|
51
|
+
export declare const projectWorkers: (project: any) => WorkerConnection[];
|
|
52
|
+
/** The workers a project's member can send jobs to: the project's own, and the host's they may use. */
|
|
53
|
+
export declare function availableWorkers(project: any, auth: Auth, config: StreamByConfig): Promise<WorkerSummary[]>;
|
|
54
|
+
/** A worker of the project, or of the host, ready to be sent a job as `auth`. */
|
|
55
|
+
export declare function resolveWorker(project: any, ref: {
|
|
56
|
+
source: string;
|
|
57
|
+
id: string;
|
|
58
|
+
}, auth: Auth, config: StreamByConfig): Promise<ResolvedWorker>;
|
|
59
|
+
export declare function checkWorkerEndpoint(value: unknown, config: StreamByConfig): string;
|
|
60
|
+
/**
|
|
61
|
+
* Throws if a project's worker endpoint resolves, now, to a local or private address (a
|
|
62
|
+
* public-looking name pointing at the server's own network).
|
|
63
|
+
*/
|
|
64
|
+
export declare function assertPublicEndpoint(endpoint: string, config: StreamByConfig): Promise<void>;
|
|
65
|
+
export interface WorkerInput {
|
|
66
|
+
name?: unknown;
|
|
67
|
+
provider?: unknown;
|
|
68
|
+
endpoint?: unknown;
|
|
69
|
+
credential?: unknown;
|
|
70
|
+
kinds?: unknown;
|
|
71
|
+
categories?: unknown;
|
|
72
|
+
autoKind?: unknown;
|
|
73
|
+
timeoutSeconds?: unknown;
|
|
74
|
+
description?: unknown;
|
|
75
|
+
}
|
|
76
|
+
/** A project's new worker, checked and with its credential encrypted. */
|
|
77
|
+
export declare function newWorkerConnection(input: WorkerInput, projectId: string, userId: string, config: StreamByConfig): WorkerConnection;
|
|
78
|
+
export declare const summarizeProjectWorker: (worker: WorkerConnection) => WorkerSummary;
|