@happyvertical/smrt-jobs 0.37.1 → 0.37.3

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.
@@ -0,0 +1,1536 @@
1
+ import { a as resolveEngine, c as unregisterLiveWorker, i as registerLiveWorker, n as isWorkerAlive, o as resolveUrl, r as offLoopEligible, s as tuneSqliteForConcurrency, t as createWorkerKey } from "./worker-liveness-C1Gjnhax.js";
2
+ import { ObjectRegistry, SmrtCollection, SmrtObject, detectEngine, ensureJobEventsSystemTableCompatibility, ensureJobsSystemTableCompatibility, field, foreignKey, getClassConfigResolvers, resolveLazyConfig, smrt } from "@happyvertical/smrt-core";
3
+ import { fromConfig } from "@happyvertical/jobs";
4
+ import { TenantContext, TenantScoped, getTenantId, tenantId } from "@happyvertical/smrt-tenancy";
5
+ import { EventEmitter } from "node:events";
6
+ import { Worker } from "node:worker_threads";
7
+ import { createLogger } from "@happyvertical/logger";
8
+ import { createId } from "@happyvertical/utils";
9
+ //#region src/__smrt-register__.ts
10
+ ObjectRegistry.registerPackageManifest(new URL("./manifest.json", "" + import.meta.url));
11
+ //#endregion
12
+ //#region src/background-policy.ts
13
+ var MAX_JOB_RETRIES = 25;
14
+ var DEFAULT_TENANT_JOB_CAP = 1e4;
15
+ function clampRetries(requested) {
16
+ if (Number.isNaN(requested) || requested < 0) return 0;
17
+ if (requested === Number.POSITIVE_INFINITY) return 25;
18
+ return Math.min(Math.floor(requested), 25);
19
+ }
20
+ var TenantJobCapExceededError = class extends Error {
21
+ constructor(tenantId, cap, current) {
22
+ super(`Tenant "${tenantId}" has reached its background-job cap (${current}/${cap} in-flight). Refusing to enqueue another job.`);
23
+ this.tenantId = tenantId;
24
+ this.cap = cap;
25
+ this.current = current;
26
+ this.name = "TenantJobCapExceededError";
27
+ }
28
+ tenantId;
29
+ cap;
30
+ current;
31
+ };
32
+ function assertWithinTenantCreationCap(tenantId, current, cap) {
33
+ if (!tenantId || cap <= 0) return;
34
+ if (current >= cap) throw new TenantJobCapExceededError(tenantId, cap, current);
35
+ }
36
+ function markBackgroundEligible(ctor, ...methods) {
37
+ const target = ctor;
38
+ const existing = target.backgroundEligibleMethods;
39
+ const set = existing instanceof Set ? new Set(existing) : new Set(existing ?? []);
40
+ for (const method of methods) set.add(method);
41
+ target.backgroundEligibleMethods = set;
42
+ }
43
+ function backgroundEligible() {
44
+ return (target, propertyKey, descriptor) => {
45
+ const ctor = target.constructor;
46
+ markBackgroundEligible(ctor, String(propertyKey));
47
+ return descriptor;
48
+ };
49
+ }
50
+ function getBackgroundEligibleMethods(ctor) {
51
+ const declared = ctor?.backgroundEligibleMethods;
52
+ if (declared == null) return null;
53
+ return declared instanceof Set ? declared : new Set(declared);
54
+ }
55
+ function isBackgroundEligibleMethod(ctor, method) {
56
+ const allow = getBackgroundEligibleMethods(ctor);
57
+ if (allow == null) return true;
58
+ return allow.has(method);
59
+ }
60
+ //#endregion
61
+ //#region src/error-redaction.ts
62
+ var REDACTED = "***REDACTED***";
63
+ var CREDENTIAL_URL_RE = /([a-z][a-z0-9+.-]*:\/\/)[^/?#@\s]+@/gi;
64
+ var BEARER_TOKEN_RE = /\b(bearer|token)\s+[A-Za-z0-9._\-+/=]{8,}/gi;
65
+ var AUTHORIZATION_HEADER_RE = /\bauthorization\s*[:=]\s*(?:[A-Za-z]+\s+)?[^\s,;)]+/gi;
66
+ var SECRET_KEY_VALUE_RE = /\b([A-Za-z0-9_-]*(?:password|passwd|pwd|secret|api[_-]?key|access[_-]?key|secret[_-]?key|private[_-]?key|client[_-]?secret|token|credential|auth)[A-Za-z0-9_-]*)\s*([:=])\s*("[^"]*"|'[^']*'|[^\s,;)]+)/gi;
67
+ var JSON_SECRET_KEY_VALUE_RE = /("(?:[A-Za-z0-9_-]*(?:password|passwd|pwd|secret|api[_-]?key|access[_-]?key|secret[_-]?key|private[_-]?key|client[_-]?secret|token|credential|auth)[A-Za-z0-9_-]*)")\s*:\s*"[^"]*"/gi;
68
+ var STANDALONE_SECRET_RE = /\b(sk-[A-Za-z0-9_-]{14,}[A-Za-z0-9]|sk_(?:live|test)_[A-Za-z0-9_-]{14,}[A-Za-z0-9]|AKIA[0-9A-Z]{12,}|gh[pousr]_[A-Za-z0-9]{20,}|AIza[A-Za-z0-9_-]{20,}|xox[bp]-[A-Za-z0-9-]{10,})\b/g;
69
+ function redactErrorMessage(message) {
70
+ if (typeof message !== "string" || message.length === 0) return message;
71
+ return message.replace(CREDENTIAL_URL_RE, `$1${REDACTED}@`).replace(AUTHORIZATION_HEADER_RE, `authorization=${REDACTED}`).replace(BEARER_TOKEN_RE, `$1 ${REDACTED}`).replace(JSON_SECRET_KEY_VALUE_RE, `$1:"${REDACTED}"`).replace(SECRET_KEY_VALUE_RE, `$1$2${REDACTED}`).replace(STANDALONE_SECRET_RE, REDACTED);
72
+ }
73
+ function redactErrorForPersistence(error) {
74
+ if (error instanceof Error) return redactErrorMessage(error.message);
75
+ return redactErrorMessage(String(error));
76
+ }
77
+ //#endregion
78
+ //#region src/logger-extension.ts
79
+ var JobContextLogger = class {
80
+ constructor(baseLogger, jobContext) {
81
+ this.baseLogger = baseLogger;
82
+ this.jobContext = jobContext;
83
+ }
84
+ baseLogger;
85
+ jobContext;
86
+ addContext(data) {
87
+ return {
88
+ ...data,
89
+ _job: {
90
+ id: this.jobContext.jobId,
91
+ attempt: this.jobContext.attempt,
92
+ queue: this.jobContext.queue,
93
+ objectType: this.jobContext.objectType,
94
+ method: this.jobContext.method
95
+ }
96
+ };
97
+ }
98
+ debug(message, data) {
99
+ this.baseLogger.debug(message, this.addContext(data));
100
+ }
101
+ info(message, data) {
102
+ this.baseLogger.info(message, this.addContext(data));
103
+ }
104
+ warn(message, data) {
105
+ this.baseLogger.warn(message, this.addContext(data));
106
+ }
107
+ error(message, data) {
108
+ this.baseLogger.error(message, this.addContext(data));
109
+ }
110
+ };
111
+ //#endregion
112
+ //#region src/smrt-job.ts
113
+ var __defProp$2 = Object.defineProperty;
114
+ var __getOwnPropDesc$2 = Object.getOwnPropertyDescriptor;
115
+ var __decorateClass$2 = (decorators, target, key, kind) => {
116
+ var result = kind > 1 ? void 0 : kind ? __getOwnPropDesc$2(target, key) : target;
117
+ for (var i = decorators.length - 1, decorator; i >= 0; i--) if (decorator = decorators[i]) result = (kind ? decorator(target, key, result) : decorator(result)) || result;
118
+ if (kind && result) __defProp$2(target, key, result);
119
+ return result;
120
+ };
121
+ var SmrtJob = class extends SmrtObject {
122
+ tenantId = void 0;
123
+ queue = "default";
124
+ objectType = "";
125
+ objectId = null;
126
+ method = "";
127
+ args = {};
128
+ runAt = /* @__PURE__ */ new Date();
129
+ priority = 50;
130
+ status = "pending";
131
+ attempts = 0;
132
+ maxAttempts = 3;
133
+ timeout = 3e5;
134
+ timeoutBehavior = "fail";
135
+ startedAt = null;
136
+ completedAt = null;
137
+ lastError = null;
138
+ resultPointer = null;
139
+ retryStrategy = {
140
+ type: "exponential",
141
+ config: {
142
+ initialDelay: 1e3,
143
+ multiplier: 2,
144
+ maxDelay: 3e5
145
+ }
146
+ };
147
+ workerId = null;
148
+ workerHeartbeat = null;
149
+ /**
150
+ * Capture ambient tenant context when a job is saved inside withTenant().
151
+ *
152
+ * Scheduled jobs can also set this explicitly from their owning schedule.
153
+ */
154
+ async save() {
155
+ if (this.tenantId === void 0) {
156
+ const contextTenantId = getTenantId();
157
+ if (contextTenantId) this.tenantId = contextTenantId;
158
+ }
159
+ return super.save();
160
+ }
161
+ /**
162
+ * Mark the job for retry
163
+ */
164
+ async retry() {
165
+ if (this.status === "completed") throw new Error("Cannot retry a completed job");
166
+ this.status = "pending";
167
+ this.attempts = 0;
168
+ this.lastError = null;
169
+ this.startedAt = null;
170
+ this.completedAt = null;
171
+ this.workerId = null;
172
+ this.workerHeartbeat = null;
173
+ await this.save();
174
+ }
175
+ /**
176
+ * Cancel the job
177
+ */
178
+ async cancel() {
179
+ if (this.status === "completed" || this.status === "cancelled") throw new Error(`Cannot cancel job with status: ${this.status}`);
180
+ this.status = "cancelled";
181
+ this.completedAt = /* @__PURE__ */ new Date();
182
+ await this.save();
183
+ }
184
+ /**
185
+ * Get a human-readable description of the job
186
+ */
187
+ getDescription() {
188
+ return `${this.objectId ? `${this.objectType}#${this.objectId}` : this.objectType}.${this.method}()`;
189
+ }
190
+ };
191
+ __decorateClass$2([tenantId({ nullable: true })], SmrtJob.prototype, "tenantId", 2);
192
+ __decorateClass$2([field({
193
+ type: "text",
194
+ required: true,
195
+ default: "default"
196
+ })], SmrtJob.prototype, "queue", 2);
197
+ __decorateClass$2([field({
198
+ type: "text",
199
+ required: true
200
+ })], SmrtJob.prototype, "objectType", 2);
201
+ __decorateClass$2([field({
202
+ type: "text",
203
+ nullable: true
204
+ })], SmrtJob.prototype, "objectId", 2);
205
+ __decorateClass$2([field({
206
+ type: "text",
207
+ required: true
208
+ })], SmrtJob.prototype, "method", 2);
209
+ __decorateClass$2([field({ type: "json" })], SmrtJob.prototype, "args", 2);
210
+ __decorateClass$2([field({
211
+ type: "datetime",
212
+ required: true
213
+ })], SmrtJob.prototype, "runAt", 2);
214
+ __decorateClass$2([field({
215
+ type: "integer",
216
+ required: true,
217
+ default: 50
218
+ })], SmrtJob.prototype, "priority", 2);
219
+ __decorateClass$2([field({
220
+ type: "text",
221
+ required: true,
222
+ default: "pending"
223
+ })], SmrtJob.prototype, "status", 2);
224
+ __decorateClass$2([field({
225
+ type: "integer",
226
+ required: true,
227
+ default: 0
228
+ })], SmrtJob.prototype, "attempts", 2);
229
+ __decorateClass$2([field({
230
+ type: "integer",
231
+ required: true,
232
+ default: 3
233
+ })], SmrtJob.prototype, "maxAttempts", 2);
234
+ __decorateClass$2([field({
235
+ type: "integer",
236
+ required: true,
237
+ default: 3e5
238
+ })], SmrtJob.prototype, "timeout", 2);
239
+ __decorateClass$2([field({
240
+ type: "text",
241
+ required: true,
242
+ default: "fail"
243
+ })], SmrtJob.prototype, "timeoutBehavior", 2);
244
+ __decorateClass$2([field({
245
+ type: "datetime",
246
+ nullable: true
247
+ })], SmrtJob.prototype, "startedAt", 2);
248
+ __decorateClass$2([field({
249
+ type: "datetime",
250
+ nullable: true
251
+ })], SmrtJob.prototype, "completedAt", 2);
252
+ __decorateClass$2([field({
253
+ type: "text",
254
+ nullable: true
255
+ })], SmrtJob.prototype, "lastError", 2);
256
+ __decorateClass$2([field({
257
+ type: "text",
258
+ nullable: true
259
+ })], SmrtJob.prototype, "resultPointer", 2);
260
+ __decorateClass$2([field({ type: "json" })], SmrtJob.prototype, "retryStrategy", 2);
261
+ __decorateClass$2([field({
262
+ type: "text",
263
+ nullable: true
264
+ })], SmrtJob.prototype, "workerId", 2);
265
+ __decorateClass$2([field({
266
+ type: "datetime",
267
+ nullable: true
268
+ })], SmrtJob.prototype, "workerHeartbeat", 2);
269
+ SmrtJob = __decorateClass$2([smrt({
270
+ tableName: "_smrt_jobs",
271
+ api: false,
272
+ cli: {
273
+ include: [
274
+ "list",
275
+ "get",
276
+ "retry",
277
+ "cancel"
278
+ ],
279
+ skipApiCheck: true,
280
+ http: false
281
+ },
282
+ mcp: false
283
+ }), TenantScoped({ mode: "optional" })], SmrtJob);
284
+ var SmrtJobCollection = class extends SmrtCollection {
285
+ static _itemClass = SmrtJob;
286
+ async initialize() {
287
+ await super.initialize();
288
+ await ensureJobsSystemTableCompatibility(this.db);
289
+ return this;
290
+ }
291
+ /**
292
+ * List jobs by status
293
+ */
294
+ async listByStatus(status, options = {}) {
295
+ const where = { status: Array.isArray(status) ? status : [status] };
296
+ if (options.queue) where.queue = options.queue;
297
+ return this.list({
298
+ where,
299
+ orderBy: ["priority DESC", "run_at ASC"],
300
+ limit: options.limit
301
+ });
302
+ }
303
+ /**
304
+ * List pending jobs ready to run
305
+ */
306
+ async listReady(options = {}) {
307
+ const now = (/* @__PURE__ */ new Date()).toISOString();
308
+ const whereConditions = ["status = 'pending'", "run_at <= ?"];
309
+ const params = [now];
310
+ if (options.queues?.length) {
311
+ const placeholders = options.queues.map(() => "?").join(", ");
312
+ whereConditions.push(`queue IN (${placeholders})`);
313
+ params.push(...options.queues);
314
+ }
315
+ params.push(options.limit || 100);
316
+ return this.query(`SELECT * FROM _smrt_jobs WHERE ${whereConditions.join(" AND ")} ORDER BY priority DESC, run_at ASC LIMIT ?`, params, { allowRawOnTenantScoped: true });
317
+ }
318
+ /**
319
+ * Atomically claim pending jobs ready to run for a worker.
320
+ *
321
+ * The claim is performed as one conditional UPDATE so concurrent workers
322
+ * cannot receive the same pending row. PostgreSQL additionally skips rows
323
+ * locked by other workers instead of waiting behind them.
324
+ */
325
+ async claimReady(options) {
326
+ const limit = options.limit ?? 100;
327
+ if (limit <= 0) return [];
328
+ const nowIso = (options.now ?? /* @__PURE__ */ new Date()).toISOString();
329
+ const whereConditions = ["status = 'pending'", "run_at <= ?"];
330
+ const whereParams = [nowIso];
331
+ if (options.queues?.length) {
332
+ const placeholders = options.queues.map(() => "?").join(", ");
333
+ whereConditions.push(`queue IN (${placeholders})`);
334
+ whereParams.push(...options.queues);
335
+ }
336
+ const lockClause = getDatabaseEngine(this.db) === "postgres" ? " FOR UPDATE SKIP LOCKED" : "";
337
+ const candidateSelect = `
338
+ SELECT id
339
+ FROM _smrt_jobs
340
+ WHERE ${whereConditions.join(" AND ")}
341
+ ORDER BY priority DESC, run_at ASC, created_at ASC, id ASC
342
+ LIMIT ?${lockClause}
343
+ `;
344
+ return (await this.query(`UPDATE _smrt_jobs
345
+ SET status = 'running',
346
+ worker_id = ?,
347
+ worker_heartbeat = ?,
348
+ started_at = ?,
349
+ attempts = attempts + 1,
350
+ updated_at = ?
351
+ WHERE id IN (${candidateSelect})
352
+ AND status = 'pending'
353
+ RETURNING *`, [
354
+ options.workerId,
355
+ nowIso,
356
+ nowIso,
357
+ nowIso,
358
+ ...whereParams,
359
+ limit
360
+ ], { allowRawOnTenantScoped: true })).toSorted(compareClaimOrder);
361
+ }
362
+ /**
363
+ * Count non-terminal (pending/running) jobs owned by a tenant.
364
+ *
365
+ * Used to enforce the per-tenant creation cap so one tenant cannot exhaust
366
+ * the shared worker pool (S5 audit #1402). Reads `_smrt_jobs` directly so it
367
+ * works regardless of ambient tenant context.
368
+ *
369
+ * @param tenantId - Tenant to count for. `null` counts global (NULL-tenant)
370
+ * jobs.
371
+ */
372
+ async countInFlightForTenant(tenantId2) {
373
+ const predicate = tenantId2 === null ? "tenant_id IS NULL" : "tenant_id = ?";
374
+ const params = tenantId2 === null ? [] : [tenantId2];
375
+ const row = (await this.db.query(`SELECT COUNT(*) AS count
376
+ FROM _smrt_jobs
377
+ WHERE status IN ('pending', 'running')
378
+ AND ${predicate}`, ...params)).rows[0];
379
+ return Number(row?.count ?? 0);
380
+ }
381
+ /**
382
+ * The single creation path for queued jobs.
383
+ *
384
+ * Centralizes the two creation-time security guards from the S5 audit (#1402)
385
+ * so every enqueue — the fluent {@link "./job-builder".JobBuilder} *and* the
386
+ * ScheduleRunner's cron-triggered jobs — goes through one place:
387
+ *
388
+ * 1. `maxAttempts` is clamped to {@link MAX_JOB_RETRIES} so a misconfigured
389
+ * caller cannot pin a worker on a poison job indefinitely.
390
+ * 2. A per-tenant in-flight cap bounds how many non-terminal jobs one tenant
391
+ * may hold, so one tenant cannot exhaust the shared worker pool
392
+ * (cross-tenant denial of service). The cap applies to the row's effective
393
+ * tenant (explicit `data.tenantId` or, when absent, the ambient context);
394
+ * global (null-tenant) jobs are exempt.
395
+ *
396
+ * Atomicity note (best-effort soft cap, by design): the cap is a
397
+ * count-then-insert, NOT a hard transactional invariant. It is intentionally
398
+ * left non-atomic. A plain transaction would not help — under the adapters'
399
+ * default isolation two concurrent same-tenant enqueues would each read the
400
+ * same COUNT and both insert, so serializing them would require either a
401
+ * per-tenant lock row (`SELECT ... FOR UPDATE`) or SERIALIZABLE-isolation
402
+ * retry loops. That cross-process locking is fragile (lock-row contention,
403
+ * adapter-specific isolation behavior, the `transaction` adapter method being
404
+ * optional) and out of proportion to the threat: this cap is defense in depth
405
+ * against runaway/accidental creation exhausting the shared worker pool, not a
406
+ * billing/quota boundary. So under truly simultaneous enqueues a tenant may
407
+ * momentarily overshoot by the number of in-flight creators; the bound still
408
+ * prevents unbounded growth and closes the prior ScheduleRunner bypass. If a
409
+ * hard guarantee is ever needed, enforce it with a DB CHECK/trigger or a
410
+ * dedicated counter row, not an application-level lock.
411
+ */
412
+ async enqueueJob(data, options = {}) {
413
+ const cap = options.tenantJobCap ?? 1e4;
414
+ const explicitTenant = typeof data.tenantId === "string" && data.tenantId.length > 0 ? data.tenantId : data.tenantId === null ? null : void 0;
415
+ const effectiveTenant = explicitTenant !== void 0 ? explicitTenant : getTenantId() ?? null;
416
+ if (effectiveTenant && cap > 0) assertWithinTenantCreationCap(effectiveTenant, await this.countInFlightForTenant(effectiveTenant), cap);
417
+ const job = await this.create({
418
+ ...data,
419
+ maxAttempts: clampRetries(data.maxAttempts ?? 3)
420
+ });
421
+ await job.save();
422
+ return job;
423
+ }
424
+ /**
425
+ * Get job statistics
426
+ */
427
+ async stats(queue) {
428
+ const query = queue ? "SELECT status, COUNT(*) as count FROM _smrt_jobs WHERE queue = ? GROUP BY status" : "SELECT status, COUNT(*) as count FROM _smrt_jobs GROUP BY status";
429
+ const params = queue ? [queue] : [];
430
+ const result = await this._db.query(query, ...params);
431
+ const counts = {};
432
+ for (const row of result.rows) counts[row.status] = row.count;
433
+ return {
434
+ pending: counts.pending ?? 0,
435
+ running: counts.running ?? 0,
436
+ completed: counts.completed ?? 0,
437
+ failed: counts.failed ?? 0,
438
+ cancelled: counts.cancelled ?? 0
439
+ };
440
+ }
441
+ /**
442
+ * Cleanup old completed/failed jobs
443
+ */
444
+ async cleanup(options) {
445
+ const conditions = [];
446
+ const params = [];
447
+ if (options.completedBefore) {
448
+ conditions.push("(status = 'completed' AND completed_at < ?)");
449
+ params.push(options.completedBefore.toISOString());
450
+ }
451
+ if (options.failedBefore) {
452
+ conditions.push("(status = 'failed' AND completed_at < ?)");
453
+ params.push(options.failedBefore.toISOString());
454
+ }
455
+ if (options.cancelledBefore) {
456
+ conditions.push("(status = 'cancelled' AND completed_at < ?)");
457
+ params.push(options.cancelledBefore.toISOString());
458
+ }
459
+ if (conditions.length === 0) return 0;
460
+ let query = `DELETE FROM _smrt_jobs WHERE (${conditions.join(" OR ")})`;
461
+ if (options.limit) {
462
+ query = `
463
+ DELETE FROM _smrt_jobs
464
+ WHERE id IN (
465
+ SELECT id FROM _smrt_jobs
466
+ WHERE (${conditions.join(" OR ")})
467
+ LIMIT ?
468
+ )
469
+ `;
470
+ params.push(options.limit);
471
+ }
472
+ return (await this._db.query(query, ...params)).rowCount ?? 0;
473
+ }
474
+ };
475
+ function getDatabaseEngine(db) {
476
+ const dbWithConfig = db;
477
+ return detectEngine(db.url || dbWithConfig.config?.url || "", dbWithConfig.type || dbWithConfig.config?.type);
478
+ }
479
+ function compareClaimOrder(left, right) {
480
+ const priority = right.priority - left.priority;
481
+ if (priority !== 0) return priority;
482
+ const runAt = left.runAt.getTime() - right.runAt.getTime();
483
+ if (runAt !== 0) return runAt;
484
+ const createdAt = timestamp(left.created_at) - timestamp(right.created_at);
485
+ if (createdAt !== 0) return createdAt;
486
+ return (left.id ?? "").localeCompare(right.id ?? "");
487
+ }
488
+ function timestamp(value) {
489
+ return value?.getTime() ?? 0;
490
+ }
491
+ //#endregion
492
+ //#region src/smrt-job-event.ts
493
+ var __defProp$1 = Object.defineProperty;
494
+ var __getOwnPropDesc$1 = Object.getOwnPropertyDescriptor;
495
+ var __decorateClass$1 = (decorators, target, key, kind) => {
496
+ var result = kind > 1 ? void 0 : kind ? __getOwnPropDesc$1(target, key) : target;
497
+ for (var i = decorators.length - 1, decorator; i >= 0; i--) if (decorator = decorators[i]) result = (kind ? decorator(target, key, result) : decorator(result)) || result;
498
+ if (kind && result) __defProp$1(target, key, result);
499
+ return result;
500
+ };
501
+ var JOB_EVENT_STORAGE_COLUMNS = [
502
+ "id",
503
+ "slug",
504
+ "context",
505
+ "created_at",
506
+ "updated_at",
507
+ "tenant_id",
508
+ "job_id",
509
+ "type",
510
+ "level",
511
+ "stage",
512
+ "progress",
513
+ "message",
514
+ "data"
515
+ ].join(", ");
516
+ var SmrtJobEvent = class extends SmrtObject {
517
+ tenantId = void 0;
518
+ jobId = "";
519
+ type = "log";
520
+ level = "info";
521
+ stage = null;
522
+ progress = null;
523
+ message = "";
524
+ data = {};
525
+ createdAt = /* @__PURE__ */ new Date();
526
+ toCursor() {
527
+ return `${this.createdAt instanceof Date ? this.createdAt.toISOString() : String(this.createdAt)}|${this.id ?? ""}`;
528
+ }
529
+ };
530
+ __decorateClass$1([tenantId({ nullable: true })], SmrtJobEvent.prototype, "tenantId", 2);
531
+ __decorateClass$1([foreignKey("SmrtJob", { required: true })], SmrtJobEvent.prototype, "jobId", 2);
532
+ __decorateClass$1([field({
533
+ type: "text",
534
+ required: true,
535
+ default: "log"
536
+ })], SmrtJobEvent.prototype, "type", 2);
537
+ __decorateClass$1([field({
538
+ type: "text",
539
+ required: true,
540
+ default: "info"
541
+ })], SmrtJobEvent.prototype, "level", 2);
542
+ __decorateClass$1([field({
543
+ type: "text",
544
+ nullable: true
545
+ })], SmrtJobEvent.prototype, "stage", 2);
546
+ __decorateClass$1([field({
547
+ type: "integer",
548
+ nullable: true
549
+ })], SmrtJobEvent.prototype, "progress", 2);
550
+ __decorateClass$1([field({
551
+ type: "text",
552
+ required: true,
553
+ default: ""
554
+ })], SmrtJobEvent.prototype, "message", 2);
555
+ __decorateClass$1([field({ type: "json" })], SmrtJobEvent.prototype, "data", 2);
556
+ __decorateClass$1([field({
557
+ type: "datetime",
558
+ required: true
559
+ })], SmrtJobEvent.prototype, "createdAt", 2);
560
+ SmrtJobEvent = __decorateClass$1([smrt({
561
+ tableName: "_smrt_job_events",
562
+ api: false,
563
+ cli: {
564
+ include: ["list", "get"],
565
+ http: false,
566
+ skipApiCheck: true
567
+ },
568
+ mcp: false
569
+ }), TenantScoped({ mode: "optional" })], SmrtJobEvent);
570
+ function normalizeLimit(limit) {
571
+ return Math.max(1, Math.min(1e3, Math.floor(typeof limit === "number" && Number.isFinite(limit) ? limit : 250)));
572
+ }
573
+ function normalizeProgress(progress) {
574
+ if (typeof progress !== "number" || !Number.isFinite(progress)) return null;
575
+ return Math.max(0, Math.min(100, Math.round(progress)));
576
+ }
577
+ function parseCursor(cursor) {
578
+ if (typeof cursor !== "string") return cursor;
579
+ const separator = cursor.lastIndexOf("|");
580
+ if (separator === -1) return {
581
+ createdAt: cursor,
582
+ id: ""
583
+ };
584
+ return {
585
+ createdAt: cursor.slice(0, separator),
586
+ id: cursor.slice(separator + 1)
587
+ };
588
+ }
589
+ function normalizeCursorDate(value) {
590
+ if (value instanceof Date) return value.toISOString();
591
+ const parsed = new Date(value);
592
+ if (!Number.isNaN(parsed.getTime())) return parsed.toISOString();
593
+ return value;
594
+ }
595
+ function usesSqliteDateFunctions(dbUrl) {
596
+ const normalized = dbUrl.toLowerCase();
597
+ return !(normalized.startsWith("postgres:") || normalized.startsWith("postgresql:"));
598
+ }
599
+ function getQueryRows(result) {
600
+ if (Array.isArray(result)) return result;
601
+ return result.rows ?? [];
602
+ }
603
+ var SmrtJobEventCollection = class extends SmrtCollection {
604
+ static _itemClass = SmrtJobEvent;
605
+ async initialize() {
606
+ await super.initialize();
607
+ await ensureJobEventsSystemTableCompatibility(this.db);
608
+ return this;
609
+ }
610
+ async append(input) {
611
+ return this.create({
612
+ tenantId: input.tenantId,
613
+ jobId: input.jobId,
614
+ type: input.type ?? "log",
615
+ level: input.level ?? "info",
616
+ stage: input.stage ?? null,
617
+ progress: normalizeProgress(input.progress),
618
+ message: input.message ?? "",
619
+ data: input.data ?? {},
620
+ createdAt: input.createdAt ?? /* @__PURE__ */ new Date()
621
+ });
622
+ }
623
+ async listByJob(jobId, options = {}) {
624
+ return this.listSinceCursor({
625
+ ...options,
626
+ jobId
627
+ });
628
+ }
629
+ async listSinceCursor(options = {}) {
630
+ const where = [];
631
+ const params = [];
632
+ if (options.jobId) {
633
+ where.push("job_id = ?");
634
+ params.push(options.jobId);
635
+ }
636
+ this.addTenantPredicate(where, params, options);
637
+ if (options.cursor) {
638
+ const cursor = parseCursor(options.cursor);
639
+ const createdAt = await this.resolveCursorCreatedAt(cursor, options);
640
+ const createdAtExpression = this.createdAtComparableExpression();
641
+ where.push(`(${createdAtExpression} > ? OR (${createdAtExpression} = ? AND id > ?))`);
642
+ params.push(createdAt, createdAt, cursor.id);
643
+ } else if (options.since) {
644
+ where.push(`${this.createdAtComparableExpression()} > ?`);
645
+ params.push(normalizeCursorDate(options.since));
646
+ }
647
+ if (options.afterId) {
648
+ where.push("id > ?");
649
+ params.push(options.afterId);
650
+ }
651
+ params.push(normalizeLimit(options.limit));
652
+ const whereSql = where.length ? `WHERE ${where.join(" AND ")}` : "";
653
+ return this.query(`SELECT ${JOB_EVENT_STORAGE_COLUMNS}
654
+ FROM _smrt_job_events
655
+ ${whereSql}
656
+ ORDER BY ${this.createdAtComparableExpression()} ASC, id ASC
657
+ LIMIT ?`, params, { allowRawOnTenantScoped: true });
658
+ }
659
+ async latestProgressByJobIds(jobIds, options = {}) {
660
+ const uniqueJobIds = [...new Set(jobIds.filter(Boolean))];
661
+ const latestByJobId = /* @__PURE__ */ new Map();
662
+ if (uniqueJobIds.length === 0) return latestByJobId;
663
+ const where = [`job_id IN (${uniqueJobIds.map(() => "?").join(", ")})`, "type = 'progress'"];
664
+ const params = [...uniqueJobIds];
665
+ this.addTenantPredicate(where, params, options);
666
+ const createdAtExpression = this.createdAtComparableExpression();
667
+ const events = await this.query(`SELECT ${JOB_EVENT_STORAGE_COLUMNS}
668
+ FROM (
669
+ SELECT ${JOB_EVENT_STORAGE_COLUMNS},
670
+ ${createdAtExpression} AS smrt_created_at_sort,
671
+ ROW_NUMBER() OVER (
672
+ PARTITION BY job_id
673
+ ORDER BY ${createdAtExpression} DESC, id DESC
674
+ ) AS smrt_rank
675
+ FROM _smrt_job_events
676
+ WHERE ${where.join(" AND ")}
677
+ ) ranked
678
+ WHERE smrt_rank = 1
679
+ ORDER BY smrt_created_at_sort DESC, id DESC`, params, { allowRawOnTenantScoped: true });
680
+ for (const event of events) latestByJobId.set(event.jobId, event);
681
+ return latestByJobId;
682
+ }
683
+ addTenantPredicate(where, params, options) {
684
+ if (options.tenantId === null) {
685
+ where.push("tenant_id IS NULL");
686
+ return;
687
+ }
688
+ const tenantId2 = typeof options.tenantId === "string" ? options.tenantId : getTenantId();
689
+ if (tenantId2) {
690
+ where.push("tenant_id = ?");
691
+ params.push(tenantId2);
692
+ return;
693
+ }
694
+ throw new Error("Tenant-scoped job event queries require tenantId, tenantId: null, or an ambient tenant context.");
695
+ }
696
+ createdAtComparableExpression() {
697
+ if (usesSqliteDateFunctions(this.db.url)) return "strftime('%Y-%m-%dT%H:%M:%fZ', created_at)";
698
+ return "created_at";
699
+ }
700
+ async resolveCursorCreatedAt(cursor, options) {
701
+ if (!cursor.id) return normalizeCursorDate(cursor.createdAt);
702
+ const where = ["id = ?"];
703
+ const params = [cursor.id];
704
+ if (options.jobId) {
705
+ where.push("job_id = ?");
706
+ params.push(options.jobId);
707
+ }
708
+ this.addTenantPredicate(where, params, options);
709
+ const cursorCreatedAt = getQueryRows(await this.db.query(`SELECT ${this.createdAtComparableExpression()} AS cursor_created_at
710
+ FROM _smrt_job_events
711
+ WHERE ${where.join(" AND ")}
712
+ LIMIT 1`, ...params))[0]?.cursor_created_at;
713
+ return typeof cursorCreatedAt === "string" && cursorCreatedAt.trim() ? cursorCreatedAt : normalizeCursorDate(cursor.createdAt);
714
+ }
715
+ };
716
+ //#endregion
717
+ //#region src/smrt-worker.ts
718
+ var __defProp = Object.defineProperty;
719
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
720
+ var __decorateClass = (decorators, target, key, kind) => {
721
+ var result = kind > 1 ? void 0 : kind ? __getOwnPropDesc(target, key) : target;
722
+ for (var i = decorators.length - 1, decorator; i >= 0; i--) if (decorator = decorators[i]) result = (kind ? decorator(target, key, result) : decorator(result)) || result;
723
+ if (kind && result) __defProp(target, key, result);
724
+ return result;
725
+ };
726
+ var SmrtWorker = class extends SmrtObject {
727
+ workerId = "";
728
+ pid = null;
729
+ hostname = null;
730
+ startedAt = null;
731
+ heartbeatAt = null;
732
+ leaseExpiresAt = null;
733
+ status = "running";
734
+ };
735
+ __decorateClass([field({
736
+ type: "text",
737
+ required: true
738
+ })], SmrtWorker.prototype, "workerId", 2);
739
+ __decorateClass([field({
740
+ type: "integer",
741
+ nullable: true
742
+ })], SmrtWorker.prototype, "pid", 2);
743
+ __decorateClass([field({
744
+ type: "text",
745
+ nullable: true
746
+ })], SmrtWorker.prototype, "hostname", 2);
747
+ __decorateClass([field({
748
+ type: "datetime",
749
+ nullable: true
750
+ })], SmrtWorker.prototype, "startedAt", 2);
751
+ __decorateClass([field({
752
+ type: "datetime",
753
+ nullable: true
754
+ })], SmrtWorker.prototype, "heartbeatAt", 2);
755
+ __decorateClass([field({
756
+ type: "datetime",
757
+ nullable: true
758
+ })], SmrtWorker.prototype, "leaseExpiresAt", 2);
759
+ __decorateClass([field({
760
+ type: "text",
761
+ required: true,
762
+ default: "running"
763
+ })], SmrtWorker.prototype, "status", 2);
764
+ SmrtWorker = __decorateClass([smrt({
765
+ tableName: "_smrt_workers",
766
+ conflictColumns: ["worker_id"],
767
+ api: false,
768
+ cli: false,
769
+ mcp: false
770
+ })], SmrtWorker);
771
+ var SmrtWorkerCollection = class extends SmrtCollection {
772
+ static _itemClass = SmrtWorker;
773
+ /**
774
+ * Fail fast if the `_smrt_workers` table has not been migrated.
775
+ *
776
+ * The framework never creates application/system tables at runtime; the
777
+ * table is created by `smrt db:migrate` (or `getTestDatabase`). A consumer
778
+ * that upgrades smrt-jobs without migrating must get a clear, actionable
779
+ * error at `start()` rather than a confusing recovery failure later.
780
+ */
781
+ async assertReady() {
782
+ try {
783
+ await this.db.query("SELECT 1 FROM _smrt_workers LIMIT 1");
784
+ } catch (error) {
785
+ throw new Error(`The _smrt_workers table is missing. Run \`smrt db:migrate\` to create job-system tables before starting a TaskRunner/ScheduleRunner. (underlying error: ${error.message})`);
786
+ }
787
+ }
788
+ /** Whether the `_smrt_workers` table exists (recovery skips lease checks if not). */
789
+ async tableReady() {
790
+ try {
791
+ await this.db.query("SELECT 1 FROM _smrt_workers LIMIT 1");
792
+ return true;
793
+ } catch {
794
+ return false;
795
+ }
796
+ }
797
+ /** Register a worker incarnation with its lease seeded to `now + ttl`. */
798
+ async registerWorker(input) {
799
+ const now = /* @__PURE__ */ new Date();
800
+ await this.create({
801
+ workerId: input.workerKey,
802
+ pid: input.pid ?? null,
803
+ hostname: input.hostname ?? null,
804
+ startedAt: now,
805
+ heartbeatAt: now,
806
+ leaseExpiresAt: new Date(now.getTime() + input.leaseTtlMs),
807
+ status: "running"
808
+ });
809
+ }
810
+ /** Renew a worker's lease to `now + ttl`. */
811
+ async renewLease(workerKey, leaseTtlMs) {
812
+ const now = /* @__PURE__ */ new Date();
813
+ await this.db.query(`UPDATE _smrt_workers
814
+ SET lease_expires_at = ?,
815
+ heartbeat_at = ?
816
+ WHERE worker_id = ?`, new Date(now.getTime() + leaseTtlMs).toISOString(), now.toISOString(), workerKey);
817
+ }
818
+ /** Remove a worker incarnation (graceful shutdown). */
819
+ async expireWorker(workerKey) {
820
+ await this.db.query("DELETE FROM _smrt_workers WHERE worker_id = ?", workerKey);
821
+ }
822
+ /** Worker keys whose database lease is still fresh (alive cross-process). */
823
+ async freshLeaseWorkerKeys() {
824
+ const result = await this.db.query(`SELECT worker_id
825
+ FROM _smrt_workers
826
+ WHERE lease_expires_at IS NOT NULL
827
+ AND lease_expires_at >= ?`, (/* @__PURE__ */ new Date()).toISOString());
828
+ const keys = /* @__PURE__ */ new Set();
829
+ for (const row of result.rows) if (typeof row.worker_id === "string") keys.add(row.worker_id);
830
+ return keys;
831
+ }
832
+ /** Delete worker rows whose lease expired more than `graceMs` ago. */
833
+ async pruneExpired(graceMs) {
834
+ const cutoff = new Date(Date.now() - Math.max(0, graceMs)).toISOString();
835
+ await this.db.query(`DELETE FROM _smrt_workers
836
+ WHERE lease_expires_at IS NOT NULL
837
+ AND lease_expires_at < ?`, cutoff);
838
+ }
839
+ };
840
+ //#endregion
841
+ //#region src/stale-recovery.ts
842
+ var DEFAULT_TASK_HEARTBEAT_INTERVAL_MS = 3e4;
843
+ var DEFAULT_LEASE_TICK_MS = 1e4;
844
+ var DEFAULT_LEASE_TTL_MS = 3e4;
845
+ var LEASE_TTL_GRACE_MULTIPLIER = 3;
846
+ function getEffectiveLeaseTtlMs(leaseTtlMs, leaseTickMs) {
847
+ return Math.max(leaseTtlMs, leaseTickMs * LEASE_TTL_GRACE_MULTIPLIER);
848
+ }
849
+ //#endregion
850
+ //#region src/runner.ts
851
+ var LIVENESS_THREAD_START_TIMEOUT_MS = 1e4;
852
+ var JobTimeoutError = class extends Error {
853
+ constructor(message) {
854
+ super(message);
855
+ this.name = "JobTimeoutError";
856
+ }
857
+ };
858
+ var DEFAULT_CONFIG = {
859
+ id: "",
860
+ concurrency: 5,
861
+ queues: ["default"],
862
+ pollInterval: 1e3,
863
+ heartbeatInterval: DEFAULT_TASK_HEARTBEAT_INTERVAL_MS,
864
+ shutdownTimeout: 3e4,
865
+ staleJobThresholdMs: 9e4,
866
+ leaseTtlMs: DEFAULT_LEASE_TTL_MS,
867
+ leaseTickMs: DEFAULT_LEASE_TICK_MS
868
+ };
869
+ var TaskRunner = class extends EventEmitter {
870
+ id;
871
+ /**
872
+ * Per-incarnation-unique worker key. Stored as the `worker_id` on claimed
873
+ * jobs and in `_smrt_workers`, so a restart of a runner sharing the same
874
+ * configured `id` does not look like it still owns the previous
875
+ * incarnation's orphaned jobs. The human-facing {@link id} stays stable for
876
+ * events/logs.
877
+ */
878
+ workerKey;
879
+ config;
880
+ effectiveLeaseTtlMs;
881
+ collection = null;
882
+ eventCollection = null;
883
+ workerCollection = null;
884
+ workersTableVerified = false;
885
+ lastRecoverySweepAt = 0;
886
+ running = false;
887
+ activeJobs = /* @__PURE__ */ new Map();
888
+ pollTimer = null;
889
+ heartbeatTimer = null;
890
+ leaseTimer = null;
891
+ livenessWorker = null;
892
+ shutdownPromise = null;
893
+ db = null;
894
+ logger = createLogger(true);
895
+ constructor(config = {}) {
896
+ super();
897
+ this.config = {
898
+ ...DEFAULT_CONFIG,
899
+ ...config,
900
+ id: config.id || `runner_${createId().slice(0, 8)}`
901
+ };
902
+ this.id = this.config.id;
903
+ this.workerKey = createWorkerKey(this.id);
904
+ this.effectiveLeaseTtlMs = getEffectiveLeaseTtlMs(this.config.leaseTtlMs, this.config.leaseTickMs);
905
+ }
906
+ /**
907
+ * Initialize the runner with database connection
908
+ */
909
+ async initialize(db) {
910
+ this.db = db;
911
+ this.collection = await SmrtJobCollection.create({ db });
912
+ this.eventCollection = await SmrtJobEventCollection.create({ db });
913
+ this.workerCollection = await SmrtWorkerCollection.create({ db });
914
+ }
915
+ /**
916
+ * Start processing jobs
917
+ */
918
+ async start() {
919
+ if (this.running) return;
920
+ if (!this.collection || !this.workerCollection) throw new Error("TaskRunner not initialized. Call initialize() first.");
921
+ await this.workerCollection.assertReady();
922
+ if (this.db && resolveEngine(this.db) === "sqlite") await tuneSqliteForConcurrency(this.db);
923
+ await this.workerCollection.registerWorker({
924
+ workerKey: this.workerKey,
925
+ pid: typeof process !== "undefined" ? process.pid : null,
926
+ hostname: typeof process !== "undefined" ? process.env.HOSTNAME ?? null : null,
927
+ leaseTtlMs: this.effectiveLeaseTtlMs
928
+ });
929
+ registerLiveWorker(this.workerKey);
930
+ this.running = true;
931
+ if (offLoopEligible(this.db)) {
932
+ if (!await this.startLivenessThread()) this.startLeaseRenewal();
933
+ } else this.startLeaseRenewal();
934
+ this.startPolling();
935
+ this.startHeartbeat();
936
+ this.emit("runner:started");
937
+ }
938
+ /**
939
+ * Stop processing jobs (graceful shutdown)
940
+ */
941
+ async stop() {
942
+ if (!this.running) return;
943
+ if (this.shutdownPromise) return this.shutdownPromise;
944
+ this.running = false;
945
+ if (this.pollTimer) {
946
+ clearTimeout(this.pollTimer);
947
+ this.pollTimer = null;
948
+ }
949
+ if (this.heartbeatTimer) {
950
+ clearInterval(this.heartbeatTimer);
951
+ this.heartbeatTimer = null;
952
+ }
953
+ this.shutdownPromise = this.waitForActiveJobs();
954
+ try {
955
+ await this.shutdownPromise;
956
+ } finally {
957
+ this.shutdownPromise = null;
958
+ await this.stopLivenessThread();
959
+ if (this.leaseTimer) {
960
+ clearInterval(this.leaseTimer);
961
+ this.leaseTimer = null;
962
+ }
963
+ unregisterLiveWorker(this.workerKey);
964
+ if (this.activeJobs.size === 0) try {
965
+ await this.workerCollection?.expireWorker(this.workerKey);
966
+ } catch {}
967
+ this.emit("runner:stopped");
968
+ }
969
+ }
970
+ /**
971
+ * Check if runner is running
972
+ */
973
+ isRunning() {
974
+ return this.running;
975
+ }
976
+ /**
977
+ * Get count of active jobs
978
+ */
979
+ activeJobCount() {
980
+ return this.activeJobs.size;
981
+ }
982
+ /**
983
+ * Start the polling loop
984
+ */
985
+ startPolling() {
986
+ const poll = async () => {
987
+ if (!this.running) return;
988
+ try {
989
+ await this.poll();
990
+ } catch (error) {
991
+ this.emit("runner:error", error);
992
+ }
993
+ if (this.running) this.pollTimer = setTimeout(poll, this.config.pollInterval);
994
+ };
995
+ poll();
996
+ }
997
+ /**
998
+ * Poll for and process jobs
999
+ */
1000
+ async poll() {
1001
+ if (!this.collection || !this.db) return;
1002
+ await this.recoverStaleJobs();
1003
+ const available = this.config.concurrency - this.activeJobs.size;
1004
+ if (available <= 0) return;
1005
+ const jobs = await this.collection.claimReady({
1006
+ workerId: this.workerKey,
1007
+ queues: this.config.queues,
1008
+ limit: available
1009
+ });
1010
+ for (const job of jobs) {
1011
+ if (!job.id) continue;
1012
+ this.processJob(job).catch((error) => {
1013
+ this.emit("runner:error", error);
1014
+ });
1015
+ }
1016
+ }
1017
+ /**
1018
+ * Process a single job.
1019
+ *
1020
+ * AT-LEAST-ONCE EXECUTION CONTRACT: a `timeout` only races the handler's
1021
+ * promise — JavaScript cannot preempt an already-running handler, so on a
1022
+ * `'fail'` (or `'kill'`) timeout the original handler keeps executing in the
1023
+ * background while the job row is failed. Timeouts are NOT auto-retried (see
1024
+ * handleJobError) precisely so a still-running handler is not duplicated by a
1025
+ * retry; but the orphaned handler's own side effects still happen, and an
1026
+ * ordinary (non-timeout) failure IS retried and re-claimable by any worker.
1027
+ * Handlers invoked from a job MUST be idempotent (e.g. keyed by
1028
+ * `context.job.jobId` or a caller-supplied idempotency key); do not rely on a
1029
+ * job body running exactly once. See AGENTS.md "Timeouts & at-least-once".
1030
+ */
1031
+ async processJob(job) {
1032
+ const jobId = job.id;
1033
+ if (!jobId) {
1034
+ this.emit("runner:error", /* @__PURE__ */ new Error("Job has no ID"));
1035
+ return;
1036
+ }
1037
+ this.activeJobs.set(jobId, job);
1038
+ this.emit("job:started", job);
1039
+ await this.appendJobEvent(job, {
1040
+ type: "status",
1041
+ level: "info",
1042
+ stage: "started",
1043
+ progress: 0,
1044
+ message: `Started job: ${job.getDescription()}`
1045
+ });
1046
+ let timeoutHandle = null;
1047
+ try {
1048
+ const result = await this.runWithTimeout(job, (handle) => {
1049
+ timeoutHandle = handle;
1050
+ });
1051
+ const completedAt = /* @__PURE__ */ new Date();
1052
+ if (await this.writeOwnedJob(jobId, {
1053
+ status: "completed",
1054
+ completed_at: completedAt.toISOString(),
1055
+ result_pointer: result?.resultPointer ?? null,
1056
+ updated_at: completedAt.toISOString()
1057
+ })) {
1058
+ job.status = "completed";
1059
+ job.completedAt = completedAt;
1060
+ job.resultPointer = result?.resultPointer ?? null;
1061
+ await this.appendJobEvent(job, {
1062
+ type: "progress",
1063
+ level: "info",
1064
+ stage: "completed",
1065
+ progress: 100,
1066
+ message: `Completed job: ${job.getDescription()}`
1067
+ });
1068
+ this.emit("job:completed", job, result);
1069
+ }
1070
+ } catch (error) {
1071
+ try {
1072
+ await this.handleJobError(job, error);
1073
+ } catch (handlerError) {
1074
+ this.emit("runner:error", handlerError);
1075
+ }
1076
+ } finally {
1077
+ if (timeoutHandle) clearTimeout(timeoutHandle);
1078
+ this.activeJobs.delete(jobId);
1079
+ }
1080
+ }
1081
+ /**
1082
+ * Execute a job honoring its {@link SmrtJob.timeoutBehavior}.
1083
+ *
1084
+ * - `'fail'` (default) and `'kill'`: race the handler against a timeout. On
1085
+ * timeout the race rejects and the caller fails/retries the job.
1086
+ * `'kill'` cannot actually preempt the handler in-process (JavaScript has
1087
+ * no thread interruption), so it is treated identically to `'fail'` — the
1088
+ * handler keeps running in the background; only the job row is failed. This
1089
+ * is documented in AGENTS.md so `'kill'` is honest about what it does
1090
+ * rather than silently behaving like a no-op.
1091
+ * - `'warn'`: do NOT fail on timeout. Arm a one-shot warning (logged + emitted
1092
+ * as a job event) at the deadline, but await the handler to completion so a
1093
+ * slow-but-successful handler still completes. This makes `'warn'` honest:
1094
+ * previously every timeout was treated as `'fail'` regardless of the
1095
+ * persisted/UI-shown behavior.
1096
+ */
1097
+ async runWithTimeout(job, captureHandle) {
1098
+ if (job.timeoutBehavior === "warn") {
1099
+ captureHandle(setTimeout(() => {
1100
+ this.logger.warn(`Job exceeded timeout (${job.timeout}ms) but timeoutBehavior='warn'; letting it finish: ${job.getDescription()}`);
1101
+ this.appendJobEvent(job, {
1102
+ type: "status",
1103
+ level: "warn",
1104
+ stage: "timeout-warning",
1105
+ message: `Job exceeded timeout of ${job.timeout}ms (timeoutBehavior='warn')`,
1106
+ data: { timeout: job.timeout }
1107
+ });
1108
+ }, job.timeout));
1109
+ return this.executeJob(job);
1110
+ }
1111
+ const timeoutPromise = new Promise((_, reject) => {
1112
+ captureHandle(setTimeout(() => {
1113
+ reject(new JobTimeoutError(`Job timeout after ${job.timeout}ms`));
1114
+ }, job.timeout));
1115
+ });
1116
+ return Promise.race([this.executeJob(job), timeoutPromise]);
1117
+ }
1118
+ /**
1119
+ * Apply a terminal/retry state transition to a job only if this worker still
1120
+ * owns it and it is still `running`. Returns whether the write applied.
1121
+ *
1122
+ * This closes the completion-vs-recovery race: if recovery already failed a
1123
+ * job out from under a finishing handler (a genuine zombie), the handler's
1124
+ * outcome is dropped rather than resurrecting the row.
1125
+ */
1126
+ async writeOwnedJob(jobId, assignments) {
1127
+ if (!this.db) return false;
1128
+ const columns = Object.keys(assignments);
1129
+ const setSql = columns.map((column) => `${column} = ?`).join(", ");
1130
+ const values = columns.map((column) => assignments[column]);
1131
+ return ((await this.db.query(`UPDATE _smrt_jobs
1132
+ SET ${setSql}
1133
+ WHERE id = ? AND worker_id = ? AND status = 'running'
1134
+ RETURNING id`, ...values, jobId, this.workerKey)).rows?.length ?? 0) > 0;
1135
+ }
1136
+ /**
1137
+ * Execute a job by invoking the method on the SmrtObject
1138
+ */
1139
+ async executeJob(job) {
1140
+ const runJob = async () => {
1141
+ const registeredClass = ObjectRegistry.getClass(job.objectType);
1142
+ if (!registeredClass) throw new Error(`Unknown object type: ${job.objectType}`);
1143
+ const ObjectClass = registeredClass.constructor;
1144
+ const rawArgs = job.args ?? {};
1145
+ const persistedAgentConfig = rawArgs._agentConfig ?? {};
1146
+ const { _agentConfig: _, _scheduleId: __, ...methodArgs } = rawArgs;
1147
+ const agentConfig = await resolveLazyConfig(persistedAgentConfig, {
1148
+ classResolvers: getClassConfigResolvers(ObjectClass),
1149
+ onError: "throw"
1150
+ });
1151
+ let instance;
1152
+ if (job.objectId) {
1153
+ instance = new ObjectClass({
1154
+ db: this.db,
1155
+ ...agentConfig
1156
+ });
1157
+ await instance.initialize();
1158
+ await instance.loadFromId(job.objectId);
1159
+ } else {
1160
+ instance = new ObjectClass({
1161
+ db: this.db,
1162
+ ...agentConfig
1163
+ });
1164
+ await instance.initialize();
1165
+ }
1166
+ const jobId = job.id;
1167
+ if (!jobId) throw new Error("Job has no ID");
1168
+ const contextLogger = new JobContextLogger(createLogger(true), {
1169
+ jobId,
1170
+ attempt: job.attempts,
1171
+ queue: job.queue,
1172
+ objectType: job.objectType,
1173
+ method: job.method
1174
+ });
1175
+ contextLogger.info(`Starting job: ${job.getDescription()}`);
1176
+ const executionContext = this.createExecutionContext(job, contextLogger);
1177
+ const method = instance[job.method];
1178
+ if (typeof method !== "function") throw new Error(`Method not found: ${job.objectType}.${job.method}`);
1179
+ if (!isBackgroundEligibleMethod(ObjectClass, job.method)) throw new Error(`Method not background-eligible: ${job.objectType}.${job.method}`);
1180
+ return { result: await method.call(instance, methodArgs, executionContext) };
1181
+ };
1182
+ if (job.tenantId) return TenantContext.runWithJobContext({ tenantId: job.tenantId }, runJob);
1183
+ return runJob();
1184
+ }
1185
+ /**
1186
+ * Handle job execution error
1187
+ */
1188
+ async handleJobError(job, error) {
1189
+ const decision = fromConfig(job.retryStrategy).shouldRetry(job.attempts, error);
1190
+ const jobId = job.id;
1191
+ if (!jobId) return;
1192
+ const isTimeout = error instanceof JobTimeoutError;
1193
+ const safeMessage = redactErrorForPersistence(error);
1194
+ if (!isTimeout && decision.shouldRetry && job.attempts < job.maxAttempts) {
1195
+ const nextRunAt = new Date(Date.now() + decision.delay);
1196
+ if (!await this.writeOwnedJob(jobId, {
1197
+ status: "pending",
1198
+ last_error: safeMessage,
1199
+ run_at: nextRunAt.toISOString(),
1200
+ worker_id: null,
1201
+ worker_heartbeat: null,
1202
+ updated_at: (/* @__PURE__ */ new Date()).toISOString()
1203
+ })) return;
1204
+ job.status = "pending";
1205
+ job.lastError = safeMessage;
1206
+ job.runAt = nextRunAt;
1207
+ job.workerId = null;
1208
+ job.workerHeartbeat = null;
1209
+ await this.appendJobEvent(job, {
1210
+ type: "status",
1211
+ level: "warn",
1212
+ stage: "retrying",
1213
+ message: `Retrying job after failure: ${safeMessage}`,
1214
+ data: {
1215
+ delay: decision.delay,
1216
+ attempts: job.attempts
1217
+ }
1218
+ });
1219
+ this.emit("job:retrying", job, error, decision.delay);
1220
+ } else {
1221
+ const completedAt = /* @__PURE__ */ new Date();
1222
+ if (!await this.writeOwnedJob(jobId, {
1223
+ status: "failed",
1224
+ completed_at: completedAt.toISOString(),
1225
+ last_error: safeMessage,
1226
+ updated_at: completedAt.toISOString()
1227
+ })) return;
1228
+ job.status = "failed";
1229
+ job.completedAt = completedAt;
1230
+ job.lastError = safeMessage;
1231
+ await this.appendJobEvent(job, {
1232
+ type: "error",
1233
+ level: "error",
1234
+ stage: "failed",
1235
+ message: safeMessage,
1236
+ data: { attempts: job.attempts }
1237
+ });
1238
+ this.emit("job:failed", job, error);
1239
+ }
1240
+ }
1241
+ createExecutionContext(job, contextLogger) {
1242
+ return {
1243
+ job: {
1244
+ jobId: job.id ?? "",
1245
+ tenantId: job.tenantId ?? null,
1246
+ attempt: job.attempts,
1247
+ queue: job.queue,
1248
+ objectType: job.objectType,
1249
+ method: job.method
1250
+ },
1251
+ logger: contextLogger,
1252
+ event: async (input) => {
1253
+ await this.appendJobEvent(job, input);
1254
+ },
1255
+ progress: async (input) => {
1256
+ const data = {
1257
+ ...input.data ?? {},
1258
+ ...input.detail ? { detail: input.detail } : {},
1259
+ ...input.source ? { source: input.source } : {}
1260
+ };
1261
+ await this.appendJobEvent(job, {
1262
+ type: "progress",
1263
+ level: "info",
1264
+ stage: input.stage,
1265
+ progress: input.progress,
1266
+ message: input.message ?? input.detail ?? `${input.stage} ${Math.round(input.progress)}%`,
1267
+ data
1268
+ });
1269
+ },
1270
+ log: async (level, message, data) => {
1271
+ contextLogger[level](message, data);
1272
+ await this.appendJobEvent(job, {
1273
+ type: level === "error" ? "error" : "log",
1274
+ level,
1275
+ message,
1276
+ data
1277
+ });
1278
+ }
1279
+ };
1280
+ }
1281
+ async appendJobEvent(job, input) {
1282
+ if (!this.eventCollection || !job.id) return null;
1283
+ try {
1284
+ const event = await this.eventCollection.append({
1285
+ tenantId: job.tenantId ?? null,
1286
+ jobId: job.id,
1287
+ type: input.type ?? "log",
1288
+ level: input.level ?? "info",
1289
+ stage: input.stage ?? null,
1290
+ progress: input.progress ?? null,
1291
+ message: input.message ?? "",
1292
+ data: input.data ?? {}
1293
+ });
1294
+ this.emit("job:event", job, event);
1295
+ if (event.type === "progress") this.emit("job:progress", job, event);
1296
+ return event;
1297
+ } catch (error) {
1298
+ const telemetryError = error instanceof Error ? error : /* @__PURE__ */ new Error(`Failed to append job telemetry: ${String(error)}`);
1299
+ try {
1300
+ this.emit("runner:error", telemetryError);
1301
+ } catch {}
1302
+ return null;
1303
+ }
1304
+ }
1305
+ /**
1306
+ * Whether the `_smrt_workers` table exists. Cached once positive — the table
1307
+ * never disappears mid-run, so this avoids a probe query on every poll.
1308
+ */
1309
+ async workersTableReady() {
1310
+ if (this.workersTableVerified) return true;
1311
+ const ready = await this.workerCollection?.tableReady() ?? false;
1312
+ if (ready) this.workersTableVerified = true;
1313
+ return ready;
1314
+ }
1315
+ /**
1316
+ * Recover jobs orphaned by dead/restarted workers.
1317
+ *
1318
+ * A `running` job is recovered only when its owning worker is *not alive*
1319
+ * (issue #1474): not live in this process and holding no fresh lease in
1320
+ * `_smrt_workers`. This is independent of the handler event loop, so a worker
1321
+ * whose handler holds the loop synchronously keeps a fresh lease (renewed off
1322
+ * the loop by the liveness thread) or stays in this process's live set, and is
1323
+ * never false-recovered. The live set takes precedence over a stale lease, and
1324
+ * a runner never recovers its own active jobs.
1325
+ *
1326
+ * Recovery is swept at most once per lease tick (not every poll), since
1327
+ * detection is TTL-bound anyway — this bounds the per-poll database load.
1328
+ */
1329
+ async recoverStaleJobs() {
1330
+ if (!this.db || !this.collection || !this.workerCollection) return;
1331
+ if (!await this.workersTableReady()) return;
1332
+ const now = Date.now();
1333
+ if (now - this.lastRecoverySweepAt < this.config.leaseTickMs) return;
1334
+ this.lastRecoverySweepAt = now;
1335
+ try {
1336
+ await this.workerCollection.pruneExpired(this.effectiveLeaseTtlMs * 10);
1337
+ } catch {}
1338
+ const freshLeaseKeys = await this.workerCollection.freshLeaseWorkerKeys();
1339
+ const running = await this.collection.query(`SELECT * FROM _smrt_jobs WHERE status = 'running'`, [], { allowRawOnTenantScoped: true });
1340
+ if (running.length === 0) return;
1341
+ const orphans = running.filter((job) => {
1342
+ const jobId = job.id;
1343
+ if (jobId && this.activeJobs.has(jobId)) return false;
1344
+ return !isWorkerAlive(job.workerId, freshLeaseKeys);
1345
+ });
1346
+ if (orphans.length === 0) return;
1347
+ const orphanIds = orphans.map((job) => job.id).filter((jobId) => typeof jobId === "string");
1348
+ if (orphanIds.length === 0) return;
1349
+ const placeholders = orphanIds.map(() => "?").join(", ");
1350
+ const recoveredAt = /* @__PURE__ */ new Date();
1351
+ const errorMessage = "Recovered orphaned running job: its owning worker is no longer alive (no fresh liveness lease in _smrt_workers and not running in this process).";
1352
+ const updated = await this.db.query(`UPDATE _smrt_jobs
1353
+ SET status = 'failed',
1354
+ completed_at = ?,
1355
+ last_error = ?,
1356
+ worker_id = NULL,
1357
+ worker_heartbeat = NULL
1358
+ WHERE status = 'running'
1359
+ AND id IN (${placeholders})
1360
+ RETURNING id`, recoveredAt.toISOString(), errorMessage, ...orphanIds);
1361
+ const recoveredIds = new Set(updated.rows.map((row) => row.id).filter((id) => typeof id === "string"));
1362
+ if (recoveredIds.size === 0) return;
1363
+ for (const job of orphans) {
1364
+ if (!job.id || !recoveredIds.has(job.id)) continue;
1365
+ job.status = "failed";
1366
+ job.completedAt = recoveredAt;
1367
+ job.lastError = errorMessage;
1368
+ job.workerId = null;
1369
+ job.workerHeartbeat = null;
1370
+ const error = /* @__PURE__ */ new Error(errorMessage);
1371
+ await this.appendJobEvent(job, {
1372
+ type: "error",
1373
+ level: "error",
1374
+ stage: "stale-recovery",
1375
+ message: errorMessage
1376
+ });
1377
+ this.emit("job:failed", job, error);
1378
+ }
1379
+ }
1380
+ /**
1381
+ * Renew this worker's liveness lease.
1382
+ *
1383
+ * In Stage 1 this runs on the main event loop, so it provides cross-process
1384
+ * detection no weaker than the old per-job heartbeat. Stage 2 moves the
1385
+ * renewal to an off-loop worker thread so a synchronous handler can no longer
1386
+ * starve it. Same-process correctness never depends on this timer — the
1387
+ * in-memory live set covers it.
1388
+ */
1389
+ startLeaseRenewal() {
1390
+ this.leaseTimer = setInterval(async () => {
1391
+ try {
1392
+ await this.workerCollection?.renewLease(this.workerKey, this.effectiveLeaseTtlMs);
1393
+ } catch {}
1394
+ }, this.config.leaseTickMs);
1395
+ }
1396
+ /**
1397
+ * Spawn the off-loop liveness thread. It opens its own connection and renews
1398
+ * this worker's lease on its own thread (unstarvable by handler CPU). Returns
1399
+ * false if the thread can't be resolved or fails to start, so the caller can
1400
+ * fall back to main-loop renewal.
1401
+ */
1402
+ async startLivenessThread() {
1403
+ if (!this.db) return false;
1404
+ let entry;
1405
+ try {
1406
+ entry = import.meta.resolve("@happyvertical/smrt-jobs/worker-liveness-thread");
1407
+ } catch {
1408
+ return false;
1409
+ }
1410
+ let worker;
1411
+ try {
1412
+ worker = new Worker(new URL(entry), { workerData: {
1413
+ url: resolveUrl(this.db),
1414
+ type: resolveEngine(this.db),
1415
+ workerKey: this.workerKey,
1416
+ leaseTtlMs: this.effectiveLeaseTtlMs,
1417
+ leaseTickMs: this.config.leaseTickMs
1418
+ } });
1419
+ } catch {
1420
+ return false;
1421
+ }
1422
+ if (!await new Promise((resolve) => {
1423
+ const onMessage = (message) => {
1424
+ if (message === "ready") {
1425
+ cleanup();
1426
+ resolve(true);
1427
+ } else if (message && typeof message === "object" && message.type === "error") {
1428
+ cleanup();
1429
+ resolve(false);
1430
+ }
1431
+ };
1432
+ const onFail = () => {
1433
+ cleanup();
1434
+ resolve(false);
1435
+ };
1436
+ const cleanup = () => {
1437
+ clearTimeout(timer);
1438
+ worker.off("message", onMessage);
1439
+ worker.off("error", onFail);
1440
+ worker.off("exit", onFail);
1441
+ };
1442
+ const timer = setTimeout(() => {
1443
+ cleanup();
1444
+ resolve(false);
1445
+ }, LIVENESS_THREAD_START_TIMEOUT_MS);
1446
+ if (typeof timer.unref === "function") timer.unref();
1447
+ worker.on("message", onMessage);
1448
+ worker.once("error", onFail);
1449
+ worker.once("exit", onFail);
1450
+ })) {
1451
+ await worker.terminate().catch(() => {});
1452
+ return false;
1453
+ }
1454
+ this.livenessWorker = worker;
1455
+ worker.unref();
1456
+ worker.once("error", () => this.handleLivenessThreadLoss(worker));
1457
+ worker.once("exit", () => this.handleLivenessThreadLoss(worker));
1458
+ return true;
1459
+ }
1460
+ handleLivenessThreadLoss(worker) {
1461
+ if (this.livenessWorker !== worker) return;
1462
+ this.livenessWorker = null;
1463
+ if (this.running && !this.leaseTimer) this.startLeaseRenewal();
1464
+ }
1465
+ /** Stop the liveness thread (graceful, with a short bound), if running. */
1466
+ async stopLivenessThread() {
1467
+ const worker = this.livenessWorker;
1468
+ if (!worker) return;
1469
+ this.livenessWorker = null;
1470
+ const stopped = new Promise((resolve) => {
1471
+ const done = () => {
1472
+ clearTimeout(timer);
1473
+ worker.off("message", onMessage);
1474
+ resolve();
1475
+ };
1476
+ const onMessage = (message) => {
1477
+ if (message === "stopped") done();
1478
+ };
1479
+ worker.on("message", onMessage);
1480
+ worker.once("exit", done);
1481
+ const timer = setTimeout(done, 2e3);
1482
+ if (typeof timer.unref === "function") timer.unref();
1483
+ });
1484
+ try {
1485
+ worker.postMessage("stop");
1486
+ } catch {}
1487
+ await stopped;
1488
+ await worker.terminate().catch(() => {});
1489
+ }
1490
+ /**
1491
+ * Per-job heartbeat loop — telemetry only ("last activity" for the UI). It no
1492
+ * longer gates recovery (that is the worker lease), so a blocked loop missing
1493
+ * a heartbeat is harmless.
1494
+ */
1495
+ startHeartbeat() {
1496
+ this.heartbeatTimer = setInterval(async () => {
1497
+ if (!this.db) return;
1498
+ const jobIds = [...this.activeJobs.keys()];
1499
+ if (jobIds.length === 0) return;
1500
+ const placeholders = jobIds.map(() => "?").join(", ");
1501
+ try {
1502
+ await this.db.query(`UPDATE _smrt_jobs
1503
+ SET worker_heartbeat = ?
1504
+ WHERE status = 'running'
1505
+ AND id IN (${placeholders})`, (/* @__PURE__ */ new Date()).toISOString(), ...jobIds);
1506
+ } catch {}
1507
+ }, this.config.heartbeatInterval);
1508
+ }
1509
+ /**
1510
+ * Wait for active jobs to complete with timeout
1511
+ */
1512
+ async waitForActiveJobs() {
1513
+ if (this.activeJobs.size === 0) return;
1514
+ return new Promise((resolve) => {
1515
+ const checkInterval = setInterval(() => {
1516
+ if (this.activeJobs.size === 0) {
1517
+ clearInterval(checkInterval);
1518
+ clearTimeout(timeout);
1519
+ resolve();
1520
+ }
1521
+ }, 100);
1522
+ const timeout = setTimeout(() => {
1523
+ clearInterval(checkInterval);
1524
+ this.logger.warn(`Shutdown timeout: ${this.activeJobs.size} jobs still active`);
1525
+ resolve();
1526
+ }, this.config.shutdownTimeout);
1527
+ });
1528
+ }
1529
+ };
1530
+ function createTaskRunner(config) {
1531
+ return new TaskRunner(config);
1532
+ }
1533
+ //#endregion
1534
+ export { markBackgroundEligible as S, assertWithinTenantCreationCap as _, SmrtWorker as a, getBackgroundEligibleMethods as b, SmrtJobEventCollection as c, JobContextLogger as d, redactErrorForPersistence as f, TenantJobCapExceededError as g, MAX_JOB_RETRIES as h, DEFAULT_TASK_HEARTBEAT_INTERVAL_MS as i, SmrtJob as l, DEFAULT_TENANT_JOB_CAP as m, TaskRunner as n, SmrtWorkerCollection as o, redactErrorMessage as p, createTaskRunner as r, SmrtJobEvent as s, JobTimeoutError as t, SmrtJobCollection as u, backgroundEligible as v, isBackgroundEligibleMethod as x, clampRetries as y };
1535
+
1536
+ //# sourceMappingURL=runner-DLsWfeY-.js.map