workmatic 1.1.2 → 1.2.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/dist/cli.cjs +1078 -90
- package/dist/cli.cjs.map +1 -1
- package/dist/cli.js +1082 -94
- package/dist/cli.js.map +1 -1
- package/dist/index.cjs +1053 -89
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +155 -5
- package/dist/index.d.ts +155 -5
- package/dist/index.js +1054 -95
- package/dist/index.js.map +1 -1
- package/package.json +16 -10
package/dist/index.js
CHANGED
|
@@ -13,6 +13,11 @@ function createDatabase(options = {}) {
|
|
|
13
13
|
sqliteDb.pragma("journal_mode = WAL");
|
|
14
14
|
sqliteDb.pragma("synchronous = NORMAL");
|
|
15
15
|
sqliteDb.pragma("busy_timeout = 5000");
|
|
16
|
+
sqliteDb.pragma("cache_size = -64000");
|
|
17
|
+
sqliteDb.pragma("temp_store = MEMORY");
|
|
18
|
+
sqliteDb.pragma("mmap_size = 268435456");
|
|
19
|
+
const cacheSize = options.statementCacheSize ?? 1e3;
|
|
20
|
+
enableStatementCache(sqliteDb, cacheSize);
|
|
16
21
|
const db = new Kysely({
|
|
17
22
|
dialect: new SqliteDialect({
|
|
18
23
|
database: sqliteDb
|
|
@@ -40,13 +45,23 @@ function createSchema(db) {
|
|
|
40
45
|
last_error TEXT
|
|
41
46
|
)
|
|
42
47
|
`);
|
|
48
|
+
ensurePartialIndex(
|
|
49
|
+
db,
|
|
50
|
+
"idx_workmatic_jobs_claim",
|
|
51
|
+
`CREATE INDEX IF NOT EXISTS idx_workmatic_jobs_claim
|
|
52
|
+
ON workmatic_jobs (queue, status, run_at, priority, id)
|
|
53
|
+
WHERE status = 'ready'`
|
|
54
|
+
);
|
|
55
|
+
ensurePartialIndex(
|
|
56
|
+
db,
|
|
57
|
+
"idx_workmatic_jobs_lease",
|
|
58
|
+
`CREATE INDEX IF NOT EXISTS idx_workmatic_jobs_lease
|
|
59
|
+
ON workmatic_jobs (status, lease_until)
|
|
60
|
+
WHERE status = 'running'`
|
|
61
|
+
);
|
|
43
62
|
db.exec(`
|
|
44
|
-
CREATE INDEX IF NOT EXISTS
|
|
45
|
-
ON workmatic_jobs (queue, status
|
|
46
|
-
`);
|
|
47
|
-
db.exec(`
|
|
48
|
-
CREATE INDEX IF NOT EXISTS idx_workmatic_jobs_lease
|
|
49
|
-
ON workmatic_jobs (status, lease_until)
|
|
63
|
+
CREATE INDEX IF NOT EXISTS idx_workmatic_jobs_queue_status
|
|
64
|
+
ON workmatic_jobs (queue, status)
|
|
50
65
|
`);
|
|
51
66
|
db.exec(`
|
|
52
67
|
CREATE TABLE IF NOT EXISTS workmatic_settings (
|
|
@@ -59,6 +74,15 @@ function createSchema(db) {
|
|
|
59
74
|
UPDATE workmatic_jobs SET status = 'dead' WHERE status = 'failed'
|
|
60
75
|
`);
|
|
61
76
|
}
|
|
77
|
+
function ensurePartialIndex(db, indexName, createSql) {
|
|
78
|
+
const row = db.prepare(
|
|
79
|
+
"SELECT sql FROM sqlite_master WHERE type = 'index' AND name = ?"
|
|
80
|
+
).get(indexName);
|
|
81
|
+
if (row?.sql && !row.sql.toUpperCase().includes("WHERE")) {
|
|
82
|
+
db.exec(`DROP INDEX IF EXISTS ${indexName}`);
|
|
83
|
+
}
|
|
84
|
+
db.exec(createSql);
|
|
85
|
+
}
|
|
62
86
|
function getUnderlyingDb(db) {
|
|
63
87
|
const mapped = kyselyToSqlite.get(db);
|
|
64
88
|
if (mapped) {
|
|
@@ -76,10 +100,41 @@ function getUnderlyingDb(db) {
|
|
|
76
100
|
"getUnderlyingDb: could not resolve better-sqlite3 instance (use createDatabase() or pass db from it)"
|
|
77
101
|
);
|
|
78
102
|
}
|
|
103
|
+
function enableStatementCache(db, maxStatements = 1e3) {
|
|
104
|
+
if (maxStatements <= 0) {
|
|
105
|
+
return db;
|
|
106
|
+
}
|
|
107
|
+
const originalPrepare = db.prepare.bind(db);
|
|
108
|
+
const cache = /* @__PURE__ */ new Map();
|
|
109
|
+
db.prepare = function(sql5) {
|
|
110
|
+
const cached = cache.get(sql5);
|
|
111
|
+
if (cached) {
|
|
112
|
+
cache.delete(sql5);
|
|
113
|
+
cache.set(sql5, cached);
|
|
114
|
+
if (!cached.busy) {
|
|
115
|
+
return cached;
|
|
116
|
+
}
|
|
117
|
+
return originalPrepare(sql5);
|
|
118
|
+
}
|
|
119
|
+
const stmt = originalPrepare(sql5);
|
|
120
|
+
if (cache.size >= maxStatements) {
|
|
121
|
+
const oldestKey = cache.keys().next().value;
|
|
122
|
+
cache.delete(oldestKey);
|
|
123
|
+
}
|
|
124
|
+
cache.set(sql5, stmt);
|
|
125
|
+
return stmt;
|
|
126
|
+
};
|
|
127
|
+
const originalClose = db.close.bind(db);
|
|
128
|
+
db.close = function() {
|
|
129
|
+
cache.clear();
|
|
130
|
+
return originalClose();
|
|
131
|
+
};
|
|
132
|
+
return db;
|
|
133
|
+
}
|
|
79
134
|
|
|
80
135
|
// src/client.ts
|
|
81
136
|
import { nanoid } from "nanoid";
|
|
82
|
-
import {
|
|
137
|
+
import { CompiledQuery } from "kysely";
|
|
83
138
|
|
|
84
139
|
// src/utils.ts
|
|
85
140
|
var defaultBackoff = (attempts) => {
|
|
@@ -109,10 +164,16 @@ function now() {
|
|
|
109
164
|
|
|
110
165
|
// src/client.ts
|
|
111
166
|
function createClient(options) {
|
|
112
|
-
const { db, queue = "default" } = options;
|
|
167
|
+
const { db, queue = "default", onJobAdded, worker } = options;
|
|
113
168
|
if (!db) {
|
|
114
169
|
throw new Error("Database instance is required");
|
|
115
170
|
}
|
|
171
|
+
function notifyJobAdded(delayMs) {
|
|
172
|
+
if (delayMs <= 0) {
|
|
173
|
+
worker?.wakeUp();
|
|
174
|
+
onJobAdded?.();
|
|
175
|
+
}
|
|
176
|
+
}
|
|
116
177
|
return {
|
|
117
178
|
/**
|
|
118
179
|
* Add a job to the queue
|
|
@@ -127,20 +188,13 @@ function createClient(options) {
|
|
|
127
188
|
const publicId = nanoid();
|
|
128
189
|
const timestamp = now();
|
|
129
190
|
const runAt = timestamp + delayMs;
|
|
130
|
-
await db.
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
attempts: 0,
|
|
138
|
-
max_attempts: maxAttempts,
|
|
139
|
-
lease_until: 0,
|
|
140
|
-
created_at: timestamp,
|
|
141
|
-
updated_at: timestamp,
|
|
142
|
-
last_error: null
|
|
143
|
-
}).execute();
|
|
191
|
+
await db.executeQuery(
|
|
192
|
+
CompiledQuery.raw(
|
|
193
|
+
`INSERT INTO workmatic_jobs (public_id, queue, payload, status, priority, run_at, attempts, max_attempts, lease_until, created_at, updated_at, last_error) VALUES (?, ?, ?, 'ready', ?, ?, 0, ?, 0, ?, ?, null)`,
|
|
194
|
+
[publicId, queue, payloadJson, priority, runAt, maxAttempts, timestamp, timestamp]
|
|
195
|
+
)
|
|
196
|
+
);
|
|
197
|
+
notifyJobAdded(delayMs);
|
|
144
198
|
return { ok: true, id: publicId };
|
|
145
199
|
},
|
|
146
200
|
async addMany(payloads, opts = {}) {
|
|
@@ -154,7 +208,7 @@ function createClient(options) {
|
|
|
154
208
|
}
|
|
155
209
|
const timestamp = now();
|
|
156
210
|
const runAt = timestamp + delayMs;
|
|
157
|
-
|
|
211
|
+
const result = await db.transaction().execute(async (trx) => {
|
|
158
212
|
const ids = [];
|
|
159
213
|
const rows = payloads.map((payload) => {
|
|
160
214
|
const payloadJson = validatePayload(payload);
|
|
@@ -178,15 +232,19 @@ function createClient(options) {
|
|
|
178
232
|
await trx.insertInto("workmatic_jobs").values(rows).execute();
|
|
179
233
|
return { ok: true, ids };
|
|
180
234
|
});
|
|
235
|
+
notifyJobAdded(delayMs);
|
|
236
|
+
return result;
|
|
181
237
|
},
|
|
182
238
|
/**
|
|
183
239
|
* Get job statistics for the queue
|
|
184
240
|
*/
|
|
185
241
|
async stats() {
|
|
186
|
-
const result = await db.
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
242
|
+
const result = await db.executeQuery(
|
|
243
|
+
CompiledQuery.raw(
|
|
244
|
+
"SELECT status, count(*) AS count FROM workmatic_jobs WHERE queue = ? GROUP BY status",
|
|
245
|
+
[queue]
|
|
246
|
+
)
|
|
247
|
+
);
|
|
190
248
|
const stats = {
|
|
191
249
|
ready: 0,
|
|
192
250
|
running: 0,
|
|
@@ -194,7 +252,7 @@ function createClient(options) {
|
|
|
194
252
|
dead: 0,
|
|
195
253
|
total: 0
|
|
196
254
|
};
|
|
197
|
-
for (const row of result) {
|
|
255
|
+
for (const row of result.rows) {
|
|
198
256
|
const status = row.status;
|
|
199
257
|
const count = Number(row.count);
|
|
200
258
|
if (status in stats) {
|
|
@@ -220,7 +278,77 @@ function createClient(options) {
|
|
|
220
278
|
|
|
221
279
|
// src/worker.ts
|
|
222
280
|
import fastq from "fastq";
|
|
223
|
-
import { sql as
|
|
281
|
+
import { sql, CompiledQuery as CompiledQuery2 } from "kysely";
|
|
282
|
+
|
|
283
|
+
// src/shutdown.ts
|
|
284
|
+
function attachGracefulShutdown(target, options = {}) {
|
|
285
|
+
const {
|
|
286
|
+
signals = ["SIGINT", "SIGTERM"],
|
|
287
|
+
timeoutMs = 3e4,
|
|
288
|
+
exitOnComplete = true,
|
|
289
|
+
exitCode = 0,
|
|
290
|
+
timeoutExitCode = 1,
|
|
291
|
+
onShutdownStart,
|
|
292
|
+
onShutdownComplete,
|
|
293
|
+
onShutdownError
|
|
294
|
+
} = options;
|
|
295
|
+
let shuttingDown = false;
|
|
296
|
+
async function stopTarget() {
|
|
297
|
+
if (Array.isArray(target)) {
|
|
298
|
+
await Promise.all(target.map((w) => w.stop()));
|
|
299
|
+
} else if ("stopAll" in target) {
|
|
300
|
+
await target.stopAll();
|
|
301
|
+
} else {
|
|
302
|
+
await target.stop();
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
const handler = async (signal) => {
|
|
306
|
+
if (shuttingDown) {
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
shuttingDown = true;
|
|
310
|
+
onShutdownStart?.(signal);
|
|
311
|
+
let timer = null;
|
|
312
|
+
if (timeoutMs > 0) {
|
|
313
|
+
timer = setTimeout(() => {
|
|
314
|
+
const err = new Error(`Graceful shutdown timed out after ${timeoutMs}ms`);
|
|
315
|
+
onShutdownError?.(err);
|
|
316
|
+
if (exitOnComplete) {
|
|
317
|
+
process.exit(timeoutExitCode);
|
|
318
|
+
}
|
|
319
|
+
}, timeoutMs);
|
|
320
|
+
timer.unref();
|
|
321
|
+
}
|
|
322
|
+
try {
|
|
323
|
+
await stopTarget();
|
|
324
|
+
if (timer) {
|
|
325
|
+
clearTimeout(timer);
|
|
326
|
+
}
|
|
327
|
+
onShutdownComplete?.();
|
|
328
|
+
if (exitOnComplete) {
|
|
329
|
+
process.exit(exitCode);
|
|
330
|
+
}
|
|
331
|
+
} catch (err) {
|
|
332
|
+
if (timer) {
|
|
333
|
+
clearTimeout(timer);
|
|
334
|
+
}
|
|
335
|
+
onShutdownError?.(err);
|
|
336
|
+
if (exitOnComplete) {
|
|
337
|
+
process.exit(timeoutExitCode);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
};
|
|
341
|
+
for (const sig of signals) {
|
|
342
|
+
process.on(sig, handler);
|
|
343
|
+
}
|
|
344
|
+
return function detach() {
|
|
345
|
+
for (const sig of signals) {
|
|
346
|
+
process.removeListener(sig, handler);
|
|
347
|
+
}
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// src/worker.ts
|
|
224
352
|
var DEFAULT_WORKER_TIMEOUT_MS = 6e4;
|
|
225
353
|
function parseClaimedRows(result) {
|
|
226
354
|
const rows = result.rows;
|
|
@@ -258,7 +386,8 @@ function createWorker(options) {
|
|
|
258
386
|
autoRestore = true,
|
|
259
387
|
pauseCheckIntervalMs = 300,
|
|
260
388
|
requeueExpiredIntervalMs = 0,
|
|
261
|
-
onPumpError
|
|
389
|
+
onPumpError,
|
|
390
|
+
completionBatchSize = 50
|
|
262
391
|
} = options;
|
|
263
392
|
if (!db) {
|
|
264
393
|
throw new Error("Database instance is required");
|
|
@@ -271,6 +400,8 @@ function createWorker(options) {
|
|
|
271
400
|
let lastPauseCheckAt = 0;
|
|
272
401
|
let cachedDbPaused = false;
|
|
273
402
|
let lastRequeueAt = 0;
|
|
403
|
+
let pendingDone = [];
|
|
404
|
+
let flushTimeout = null;
|
|
274
405
|
function notifyPumpError(error) {
|
|
275
406
|
console.error("[workmatic] Pump error:", error);
|
|
276
407
|
onPumpError?.(error);
|
|
@@ -282,13 +413,16 @@ function createWorker(options) {
|
|
|
282
413
|
if (!persistState) return;
|
|
283
414
|
const timestamp = now();
|
|
284
415
|
const key = getStateKey();
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
416
|
+
try {
|
|
417
|
+
await sql`
|
|
418
|
+
INSERT INTO workmatic_settings (queue, paused, updated_at)
|
|
419
|
+
VALUES (${key}, ${state === "paused" ? 1 : state === "running" ? 2 : 0}, ${timestamp})
|
|
420
|
+
ON CONFLICT(queue) DO UPDATE SET
|
|
421
|
+
paused = ${state === "paused" ? 1 : state === "running" ? 2 : 0},
|
|
422
|
+
updated_at = ${timestamp}
|
|
423
|
+
`.execute(db);
|
|
424
|
+
} catch {
|
|
425
|
+
}
|
|
292
426
|
}
|
|
293
427
|
async function loadState() {
|
|
294
428
|
if (!persistState) return null;
|
|
@@ -315,52 +449,104 @@ function createWorker(options) {
|
|
|
315
449
|
async function claimBatch(limit) {
|
|
316
450
|
const timestamp = now();
|
|
317
451
|
const leaseUntil = timestamp + leaseMs;
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
452
|
+
const result = await sql`
|
|
453
|
+
UPDATE workmatic_jobs
|
|
454
|
+
SET status = 'running', lease_until = ${leaseUntil}, updated_at = ${timestamp}
|
|
455
|
+
WHERE rowid IN (
|
|
456
|
+
SELECT rowid FROM workmatic_jobs
|
|
457
|
+
WHERE queue = ${queue}
|
|
458
|
+
AND status = 'ready'
|
|
459
|
+
AND run_at <= ${timestamp}
|
|
460
|
+
ORDER BY priority ASC, id ASC
|
|
461
|
+
LIMIT ${limit}
|
|
462
|
+
)
|
|
463
|
+
RETURNING id, public_id, queue, payload, attempts, max_attempts, priority, created_at, last_error
|
|
464
|
+
`.execute(db);
|
|
465
|
+
return parseClaimedRows(result);
|
|
466
|
+
}
|
|
467
|
+
async function flushDoneBatch() {
|
|
468
|
+
if (flushTimeout !== null) {
|
|
469
|
+
clearImmediate(flushTimeout);
|
|
470
|
+
flushTimeout = null;
|
|
471
|
+
}
|
|
472
|
+
if (pendingDone.length === 0) {
|
|
473
|
+
return;
|
|
474
|
+
}
|
|
475
|
+
const current = pendingDone;
|
|
476
|
+
pendingDone = [];
|
|
477
|
+
const timestamp = now();
|
|
478
|
+
try {
|
|
479
|
+
if (current.length === 1) {
|
|
480
|
+
await db.executeQuery(
|
|
481
|
+
CompiledQuery2.raw(
|
|
482
|
+
"UPDATE workmatic_jobs SET status = 'done', lease_until = 0, updated_at = ? WHERE id = ?",
|
|
483
|
+
[timestamp, current[0].id]
|
|
484
|
+
)
|
|
485
|
+
);
|
|
486
|
+
} else {
|
|
487
|
+
const CHUNK_SIZE = 500;
|
|
488
|
+
for (let i = 0; i < current.length; i += CHUNK_SIZE) {
|
|
489
|
+
const chunk = current.slice(i, i + CHUNK_SIZE);
|
|
490
|
+
const placeholders = chunk.map(() => "?").join(", ");
|
|
491
|
+
const params = [timestamp, ...chunk.map((item) => item.id)];
|
|
492
|
+
await db.executeQuery(
|
|
493
|
+
CompiledQuery2.raw(
|
|
494
|
+
`UPDATE workmatic_jobs SET status = 'done', lease_until = 0, updated_at = ? WHERE id IN (${placeholders})`,
|
|
495
|
+
params
|
|
496
|
+
)
|
|
497
|
+
);
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
for (const item of current) {
|
|
501
|
+
item.resolve();
|
|
502
|
+
}
|
|
503
|
+
} catch (err) {
|
|
504
|
+
for (const item of current) {
|
|
505
|
+
item.reject(err);
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
function markDone(jobId) {
|
|
510
|
+
if (completionBatchSize <= 0) {
|
|
511
|
+
return db.executeQuery(
|
|
512
|
+
CompiledQuery2.raw(
|
|
513
|
+
"UPDATE workmatic_jobs SET status = 'done', lease_until = 0, updated_at = ? WHERE id = ?",
|
|
514
|
+
[now(), jobId]
|
|
329
515
|
)
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
516
|
+
).then(() => {
|
|
517
|
+
});
|
|
518
|
+
}
|
|
519
|
+
return new Promise((resolve, reject) => {
|
|
520
|
+
pendingDone.push({ id: jobId, resolve, reject });
|
|
521
|
+
if (pendingDone.length >= completionBatchSize) {
|
|
522
|
+
void flushDoneBatch();
|
|
523
|
+
} else if (!flushTimeout) {
|
|
524
|
+
flushTimeout = setImmediate(() => {
|
|
525
|
+
flushTimeout = null;
|
|
526
|
+
void flushDoneBatch();
|
|
527
|
+
});
|
|
528
|
+
}
|
|
333
529
|
});
|
|
334
530
|
}
|
|
335
|
-
async function markDone(jobId) {
|
|
336
|
-
await db.updateTable("workmatic_jobs").set({
|
|
337
|
-
status: "done",
|
|
338
|
-
lease_until: 0,
|
|
339
|
-
updated_at: now()
|
|
340
|
-
}).where("id", "=", jobId).execute();
|
|
341
|
-
}
|
|
342
531
|
async function markFailed(jobId, attempts, maxAttempts, error) {
|
|
343
532
|
const timestamp = now();
|
|
344
533
|
const newAttempts = attempts + 1;
|
|
345
534
|
const errorMessage = error.message || String(error);
|
|
346
535
|
if (newAttempts < maxAttempts) {
|
|
347
536
|
const runAt = timestamp + backoff(newAttempts);
|
|
348
|
-
await db.
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
updated_at: timestamp
|
|
355
|
-
}).where("id", "=", jobId).execute();
|
|
537
|
+
await db.executeQuery(
|
|
538
|
+
CompiledQuery2.raw(
|
|
539
|
+
"UPDATE workmatic_jobs SET status = 'ready', attempts = ?, run_at = ?, lease_until = 0, last_error = ?, updated_at = ? WHERE id = ?",
|
|
540
|
+
[newAttempts, runAt, errorMessage, timestamp, jobId]
|
|
541
|
+
)
|
|
542
|
+
);
|
|
356
543
|
} else {
|
|
357
|
-
await db.
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
}).where("id", "=", jobId).execute();
|
|
544
|
+
await db.executeQuery(
|
|
545
|
+
CompiledQuery2.raw(
|
|
546
|
+
"UPDATE workmatic_jobs SET status = 'dead', attempts = ?, lease_until = 0, last_error = ?, updated_at = ? WHERE id = ?",
|
|
547
|
+
[newAttempts, errorMessage, timestamp, jobId]
|
|
548
|
+
)
|
|
549
|
+
);
|
|
364
550
|
}
|
|
365
551
|
}
|
|
366
552
|
async function withTimeout(promise, ms, jobId) {
|
|
@@ -407,8 +593,13 @@ function createWorker(options) {
|
|
|
407
593
|
}
|
|
408
594
|
}
|
|
409
595
|
async function isQueuePausedInDb() {
|
|
410
|
-
const
|
|
411
|
-
|
|
596
|
+
const result = await db.executeQuery(
|
|
597
|
+
CompiledQuery2.raw(
|
|
598
|
+
"SELECT paused FROM workmatic_settings WHERE queue = ? LIMIT 1",
|
|
599
|
+
[queue]
|
|
600
|
+
)
|
|
601
|
+
);
|
|
602
|
+
return result.rows[0]?.paused === 1;
|
|
412
603
|
}
|
|
413
604
|
async function pump() {
|
|
414
605
|
if (!running) {
|
|
@@ -463,8 +654,7 @@ function createWorker(options) {
|
|
|
463
654
|
running = true;
|
|
464
655
|
paused = false;
|
|
465
656
|
fastqQueue = fastq.promise(processJob, concurrency);
|
|
466
|
-
saveState("running")
|
|
467
|
-
});
|
|
657
|
+
void saveState("running");
|
|
468
658
|
pump();
|
|
469
659
|
},
|
|
470
660
|
async stop() {
|
|
@@ -476,27 +666,28 @@ function createWorker(options) {
|
|
|
476
666
|
clearTimeout(pumpTimeout);
|
|
477
667
|
pumpTimeout = null;
|
|
478
668
|
}
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
}
|
|
669
|
+
await fastqQueue.drained();
|
|
670
|
+
fastqQueue = null;
|
|
671
|
+
await flushDoneBatch();
|
|
483
672
|
await saveState("stopped");
|
|
484
673
|
},
|
|
485
674
|
pause() {
|
|
486
675
|
paused = true;
|
|
487
|
-
|
|
488
|
-
|
|
676
|
+
void flushDoneBatch();
|
|
677
|
+
void saveState("paused");
|
|
489
678
|
},
|
|
490
679
|
resume() {
|
|
491
680
|
paused = false;
|
|
492
|
-
saveState("running")
|
|
493
|
-
});
|
|
681
|
+
void saveState("running");
|
|
494
682
|
},
|
|
495
683
|
async stats() {
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
684
|
+
await flushDoneBatch();
|
|
685
|
+
const result = await db.executeQuery(
|
|
686
|
+
CompiledQuery2.raw(
|
|
687
|
+
"SELECT status, count(*) AS count FROM workmatic_jobs WHERE queue = ? GROUP BY status",
|
|
688
|
+
[queue]
|
|
689
|
+
)
|
|
690
|
+
);
|
|
500
691
|
const stats = {
|
|
501
692
|
ready: 0,
|
|
502
693
|
running: 0,
|
|
@@ -504,7 +695,7 @@ function createWorker(options) {
|
|
|
504
695
|
dead: 0,
|
|
505
696
|
total: 0
|
|
506
697
|
};
|
|
507
|
-
for (const row of result) {
|
|
698
|
+
for (const row of result.rows) {
|
|
508
699
|
const status = row.status;
|
|
509
700
|
const count = Number(row.count);
|
|
510
701
|
if (status in stats) {
|
|
@@ -540,6 +731,22 @@ function createWorker(options) {
|
|
|
540
731
|
}
|
|
541
732
|
const result = await query.execute();
|
|
542
733
|
return Number(result[0]?.numDeletedRows ?? 0);
|
|
734
|
+
},
|
|
735
|
+
wakeUp() {
|
|
736
|
+
if (!running || paused) {
|
|
737
|
+
return;
|
|
738
|
+
}
|
|
739
|
+
if (pumpTimeout) {
|
|
740
|
+
clearTimeout(pumpTimeout);
|
|
741
|
+
pumpTimeout = null;
|
|
742
|
+
}
|
|
743
|
+
pumpTimeout = setTimeout(pump, 0);
|
|
744
|
+
},
|
|
745
|
+
async flushCompletions() {
|
|
746
|
+
await flushDoneBatch();
|
|
747
|
+
},
|
|
748
|
+
attachSignalHandlers(options2) {
|
|
749
|
+
return attachGracefulShutdown(this, options2);
|
|
543
750
|
}
|
|
544
751
|
};
|
|
545
752
|
if (persistState && autoRestore) {
|
|
@@ -553,7 +760,7 @@ function createWorker(options) {
|
|
|
553
760
|
}
|
|
554
761
|
|
|
555
762
|
// src/orchestrator.ts
|
|
556
|
-
import { sql as
|
|
763
|
+
import { sql as sql2 } from "kysely";
|
|
557
764
|
var DEFAULT_TRANSFER_STATUSES = ["ready", "dead"];
|
|
558
765
|
var DEFAULT_TRANSFER_LIMIT = 1e4;
|
|
559
766
|
function rowsUpdated(result) {
|
|
@@ -573,7 +780,15 @@ function createOrchestrator(options) {
|
|
|
573
780
|
function ensureEntry(queue) {
|
|
574
781
|
let entry = registry.get(queue);
|
|
575
782
|
if (!entry) {
|
|
576
|
-
entry = {
|
|
783
|
+
entry = {
|
|
784
|
+
client: createClient({
|
|
785
|
+
db,
|
|
786
|
+
queue,
|
|
787
|
+
onJobAdded: () => {
|
|
788
|
+
registry.get(queue)?.worker?.wakeUp();
|
|
789
|
+
}
|
|
790
|
+
})
|
|
791
|
+
};
|
|
577
792
|
registry.set(queue, entry);
|
|
578
793
|
}
|
|
579
794
|
return entry;
|
|
@@ -581,7 +796,7 @@ function createOrchestrator(options) {
|
|
|
581
796
|
async function setQueuePaused(queue, paused) {
|
|
582
797
|
const timestamp = now();
|
|
583
798
|
const value = paused ? 1 : 0;
|
|
584
|
-
await
|
|
799
|
+
await sql2`
|
|
585
800
|
INSERT INTO workmatic_settings (queue, paused, updated_at)
|
|
586
801
|
VALUES (${queue}, ${value}, ${timestamp})
|
|
587
802
|
ON CONFLICT(queue) DO UPDATE SET
|
|
@@ -746,6 +961,9 @@ function createOrchestrator(options) {
|
|
|
746
961
|
return;
|
|
747
962
|
}
|
|
748
963
|
await db.updateTable("workmatic_jobs").set({ queue: toQueue, updated_at: timestamp }).where("public_id", "=", publicId).execute();
|
|
964
|
+
},
|
|
965
|
+
attachSignalHandlers(options2) {
|
|
966
|
+
return attachGracefulShutdown(this, options2);
|
|
749
967
|
}
|
|
750
968
|
};
|
|
751
969
|
return orchestrator;
|
|
@@ -756,7 +974,7 @@ import { createServer } from "http";
|
|
|
756
974
|
import { fileURLToPath } from "url";
|
|
757
975
|
import { dirname, join } from "path";
|
|
758
976
|
import { readFile } from "fs/promises";
|
|
759
|
-
import { sql as
|
|
977
|
+
import { sql as sql3 } from "kysely";
|
|
760
978
|
var __filename2 = fileURLToPath(import.meta.url);
|
|
761
979
|
var __dirname2 = dirname(__filename2);
|
|
762
980
|
var CONTENT_TYPES = {
|
|
@@ -839,7 +1057,7 @@ function createRequestHandler(db, workerMap, basePath = "") {
|
|
|
839
1057
|
const queueFilter = query.get("queue");
|
|
840
1058
|
let statsQuery = db.selectFrom("workmatic_jobs").select([
|
|
841
1059
|
"status",
|
|
842
|
-
|
|
1060
|
+
sql3`count(*)`.as("count")
|
|
843
1061
|
]).groupBy("status");
|
|
844
1062
|
if (queueFilter) {
|
|
845
1063
|
statsQuery = statsQuery.where("queue", "=", queueFilter);
|
|
@@ -1040,15 +1258,756 @@ function createDashboardMiddleware(options) {
|
|
|
1040
1258
|
void handleRequest(req, res, next);
|
|
1041
1259
|
};
|
|
1042
1260
|
}
|
|
1261
|
+
|
|
1262
|
+
// src/mcp/server.ts
|
|
1263
|
+
import readline from "readline";
|
|
1264
|
+
import { EventEmitter } from "events";
|
|
1265
|
+
|
|
1266
|
+
// src/mcp/tools.ts
|
|
1267
|
+
import { sql as sql4 } from "kysely";
|
|
1268
|
+
var MCP_TOOL_DEFINITIONS = [
|
|
1269
|
+
{
|
|
1270
|
+
name: "workmatic_list_queues",
|
|
1271
|
+
description: "List all queues present in the Workmatic database along with job counts",
|
|
1272
|
+
inputSchema: {
|
|
1273
|
+
type: "object",
|
|
1274
|
+
properties: {}
|
|
1275
|
+
}
|
|
1276
|
+
},
|
|
1277
|
+
{
|
|
1278
|
+
name: "workmatic_get_stats",
|
|
1279
|
+
description: "Get real-time job counts (ready, running, done, dead, total) for a specific queue or all queues",
|
|
1280
|
+
inputSchema: {
|
|
1281
|
+
type: "object",
|
|
1282
|
+
properties: {
|
|
1283
|
+
queue: {
|
|
1284
|
+
type: "string",
|
|
1285
|
+
description: "Optional queue name. If omitted, stats for all queues will be returned."
|
|
1286
|
+
}
|
|
1287
|
+
}
|
|
1288
|
+
}
|
|
1289
|
+
},
|
|
1290
|
+
{
|
|
1291
|
+
name: "workmatic_list_jobs",
|
|
1292
|
+
description: "List jobs in the database filtered by queue, status, and limit",
|
|
1293
|
+
inputSchema: {
|
|
1294
|
+
type: "object",
|
|
1295
|
+
properties: {
|
|
1296
|
+
queue: {
|
|
1297
|
+
type: "string",
|
|
1298
|
+
description: "Filter by queue name"
|
|
1299
|
+
},
|
|
1300
|
+
status: {
|
|
1301
|
+
type: "string",
|
|
1302
|
+
enum: ["ready", "running", "done", "dead"],
|
|
1303
|
+
description: "Filter by job status"
|
|
1304
|
+
},
|
|
1305
|
+
limit: {
|
|
1306
|
+
type: "number",
|
|
1307
|
+
description: "Maximum number of jobs to return (default: 20, max: 100)"
|
|
1308
|
+
},
|
|
1309
|
+
offset: {
|
|
1310
|
+
type: "number",
|
|
1311
|
+
description: "Number of jobs to skip for pagination (default: 0)"
|
|
1312
|
+
}
|
|
1313
|
+
}
|
|
1314
|
+
}
|
|
1315
|
+
},
|
|
1316
|
+
{
|
|
1317
|
+
name: "workmatic_get_dead_jobs",
|
|
1318
|
+
description: "Retrieve failed/dead jobs with error details and payloads for debugging",
|
|
1319
|
+
inputSchema: {
|
|
1320
|
+
type: "object",
|
|
1321
|
+
properties: {
|
|
1322
|
+
queue: {
|
|
1323
|
+
type: "string",
|
|
1324
|
+
description: "Filter dead jobs by queue name"
|
|
1325
|
+
},
|
|
1326
|
+
limit: {
|
|
1327
|
+
type: "number",
|
|
1328
|
+
description: "Maximum number of dead jobs to return (default: 20)"
|
|
1329
|
+
}
|
|
1330
|
+
}
|
|
1331
|
+
}
|
|
1332
|
+
},
|
|
1333
|
+
{
|
|
1334
|
+
name: "workmatic_add_job",
|
|
1335
|
+
description: "Enqueue a new background job into Workmatic",
|
|
1336
|
+
inputSchema: {
|
|
1337
|
+
type: "object",
|
|
1338
|
+
properties: {
|
|
1339
|
+
queue: {
|
|
1340
|
+
type: "string",
|
|
1341
|
+
description: 'Target queue name (default: "default")'
|
|
1342
|
+
},
|
|
1343
|
+
payload: {
|
|
1344
|
+
description: "Job payload (JSON object, string, number, etc.)"
|
|
1345
|
+
},
|
|
1346
|
+
priority: {
|
|
1347
|
+
type: "number",
|
|
1348
|
+
description: "Job priority (lower number = higher priority, default: 0)"
|
|
1349
|
+
},
|
|
1350
|
+
delayMs: {
|
|
1351
|
+
type: "number",
|
|
1352
|
+
description: "Delay in milliseconds before job can run (default: 0)"
|
|
1353
|
+
},
|
|
1354
|
+
maxAttempts: {
|
|
1355
|
+
type: "number",
|
|
1356
|
+
description: "Maximum execution retry attempts (default: 3)"
|
|
1357
|
+
}
|
|
1358
|
+
},
|
|
1359
|
+
required: ["payload"]
|
|
1360
|
+
}
|
|
1361
|
+
},
|
|
1362
|
+
{
|
|
1363
|
+
name: "workmatic_retry_job",
|
|
1364
|
+
description: "Retry a specific dead or failed job by resetting it to ready status",
|
|
1365
|
+
inputSchema: {
|
|
1366
|
+
type: "object",
|
|
1367
|
+
properties: {
|
|
1368
|
+
publicId: {
|
|
1369
|
+
type: "string",
|
|
1370
|
+
description: "Public ID of the job to retry"
|
|
1371
|
+
}
|
|
1372
|
+
},
|
|
1373
|
+
required: ["publicId"]
|
|
1374
|
+
}
|
|
1375
|
+
},
|
|
1376
|
+
{
|
|
1377
|
+
name: "workmatic_retry_all_dead",
|
|
1378
|
+
description: "Retry all dead jobs (optionally in a specific queue) by resetting them to ready status",
|
|
1379
|
+
inputSchema: {
|
|
1380
|
+
type: "object",
|
|
1381
|
+
properties: {
|
|
1382
|
+
queue: {
|
|
1383
|
+
type: "string",
|
|
1384
|
+
description: "Optional queue name to restrict retrying"
|
|
1385
|
+
}
|
|
1386
|
+
}
|
|
1387
|
+
}
|
|
1388
|
+
},
|
|
1389
|
+
{
|
|
1390
|
+
name: "workmatic_pause_queue",
|
|
1391
|
+
description: "Pause a queue so workers stop claiming new jobs from it",
|
|
1392
|
+
inputSchema: {
|
|
1393
|
+
type: "object",
|
|
1394
|
+
properties: {
|
|
1395
|
+
queue: {
|
|
1396
|
+
type: "string",
|
|
1397
|
+
description: "Queue name to pause"
|
|
1398
|
+
}
|
|
1399
|
+
},
|
|
1400
|
+
required: ["queue"]
|
|
1401
|
+
}
|
|
1402
|
+
},
|
|
1403
|
+
{
|
|
1404
|
+
name: "workmatic_resume_queue",
|
|
1405
|
+
description: "Resume a paused queue so workers resume claiming jobs",
|
|
1406
|
+
inputSchema: {
|
|
1407
|
+
type: "object",
|
|
1408
|
+
properties: {
|
|
1409
|
+
queue: {
|
|
1410
|
+
type: "string",
|
|
1411
|
+
description: "Queue name to resume"
|
|
1412
|
+
}
|
|
1413
|
+
},
|
|
1414
|
+
required: ["queue"]
|
|
1415
|
+
}
|
|
1416
|
+
},
|
|
1417
|
+
{
|
|
1418
|
+
name: "workmatic_purge_jobs",
|
|
1419
|
+
description: "Permanently remove done or dead jobs from the database",
|
|
1420
|
+
inputSchema: {
|
|
1421
|
+
type: "object",
|
|
1422
|
+
properties: {
|
|
1423
|
+
queue: {
|
|
1424
|
+
type: "string",
|
|
1425
|
+
description: "Queue name to purge jobs from (optional)"
|
|
1426
|
+
},
|
|
1427
|
+
status: {
|
|
1428
|
+
type: "string",
|
|
1429
|
+
enum: ["done", "dead", "all"],
|
|
1430
|
+
description: 'Status of jobs to purge (default: "done")'
|
|
1431
|
+
}
|
|
1432
|
+
}
|
|
1433
|
+
}
|
|
1434
|
+
},
|
|
1435
|
+
{
|
|
1436
|
+
name: "workmatic_transfer_jobs",
|
|
1437
|
+
description: "Move jobs from one queue to another (e.g. from dead-letter queue back to primary)",
|
|
1438
|
+
inputSchema: {
|
|
1439
|
+
type: "object",
|
|
1440
|
+
properties: {
|
|
1441
|
+
fromQueue: {
|
|
1442
|
+
type: "string",
|
|
1443
|
+
description: "Source queue name"
|
|
1444
|
+
},
|
|
1445
|
+
toQueue: {
|
|
1446
|
+
type: "string",
|
|
1447
|
+
description: "Destination queue name"
|
|
1448
|
+
},
|
|
1449
|
+
status: {
|
|
1450
|
+
type: "string",
|
|
1451
|
+
enum: ["ready", "dead"],
|
|
1452
|
+
description: 'Status of jobs to transfer (default: "ready")'
|
|
1453
|
+
},
|
|
1454
|
+
limit: {
|
|
1455
|
+
type: "number",
|
|
1456
|
+
description: "Maximum number of jobs to transfer (default: 1000)"
|
|
1457
|
+
},
|
|
1458
|
+
resetForRetry: {
|
|
1459
|
+
type: "boolean",
|
|
1460
|
+
description: "If transferring dead jobs, reset their status to ready (default: false)"
|
|
1461
|
+
}
|
|
1462
|
+
},
|
|
1463
|
+
required: ["fromQueue", "toQueue"]
|
|
1464
|
+
}
|
|
1465
|
+
},
|
|
1466
|
+
{
|
|
1467
|
+
name: "workmatic_update_job_status",
|
|
1468
|
+
description: "Update the status of a specific job (ready, done, dead) and signal workers / listeners",
|
|
1469
|
+
inputSchema: {
|
|
1470
|
+
type: "object",
|
|
1471
|
+
properties: {
|
|
1472
|
+
publicId: {
|
|
1473
|
+
type: "string",
|
|
1474
|
+
description: "Public ID of the job to update"
|
|
1475
|
+
},
|
|
1476
|
+
status: {
|
|
1477
|
+
type: "string",
|
|
1478
|
+
enum: ["ready", "done", "dead"],
|
|
1479
|
+
description: "New status for the job"
|
|
1480
|
+
},
|
|
1481
|
+
error: {
|
|
1482
|
+
type: "string",
|
|
1483
|
+
description: "Optional error message when marking as dead or recording failure details"
|
|
1484
|
+
},
|
|
1485
|
+
resetAttempts: {
|
|
1486
|
+
type: "boolean",
|
|
1487
|
+
description: "Whether to reset attempts to 0 (default: true if status is ready, false otherwise)"
|
|
1488
|
+
},
|
|
1489
|
+
delayMs: {
|
|
1490
|
+
type: "number",
|
|
1491
|
+
description: "Delay in milliseconds before the job becomes ready (default: 0)"
|
|
1492
|
+
}
|
|
1493
|
+
},
|
|
1494
|
+
required: ["publicId", "status"]
|
|
1495
|
+
}
|
|
1496
|
+
}
|
|
1497
|
+
];
|
|
1498
|
+
async function executeTool(db, name, args = {}, context) {
|
|
1499
|
+
switch (name) {
|
|
1500
|
+
case "workmatic_list_queues": {
|
|
1501
|
+
const qJobs = await db.selectFrom("workmatic_jobs").select("queue").distinct().execute();
|
|
1502
|
+
const qSettings = await db.selectFrom("workmatic_settings").select("queue").distinct().execute();
|
|
1503
|
+
const set = /* @__PURE__ */ new Set();
|
|
1504
|
+
for (const r of qJobs) set.add(r.queue);
|
|
1505
|
+
for (const r of qSettings) {
|
|
1506
|
+
if (!r.queue.startsWith("worker_state_")) {
|
|
1507
|
+
set.add(r.queue);
|
|
1508
|
+
}
|
|
1509
|
+
}
|
|
1510
|
+
const queueList = Array.from(set).sort();
|
|
1511
|
+
const result = [];
|
|
1512
|
+
for (const q of queueList) {
|
|
1513
|
+
const client = createClient({ db, queue: q });
|
|
1514
|
+
const stats = await client.stats();
|
|
1515
|
+
const setting = await db.selectFrom("workmatic_settings").select("paused").where("queue", "=", q).executeTakeFirst();
|
|
1516
|
+
result.push({
|
|
1517
|
+
queue: q,
|
|
1518
|
+
stats: {
|
|
1519
|
+
ready: stats.ready,
|
|
1520
|
+
running: stats.running,
|
|
1521
|
+
done: stats.done,
|
|
1522
|
+
dead: stats.dead,
|
|
1523
|
+
total: stats.total
|
|
1524
|
+
},
|
|
1525
|
+
isPaused: setting?.paused === 1
|
|
1526
|
+
});
|
|
1527
|
+
}
|
|
1528
|
+
return { queues: result, totalQueues: result.length };
|
|
1529
|
+
}
|
|
1530
|
+
case "workmatic_get_stats": {
|
|
1531
|
+
const queue = args.queue;
|
|
1532
|
+
if (queue) {
|
|
1533
|
+
const client = createClient({ db, queue });
|
|
1534
|
+
return { queue, stats: await client.stats() };
|
|
1535
|
+
}
|
|
1536
|
+
const listRes = await executeTool(db, "workmatic_list_queues");
|
|
1537
|
+
const summary = {};
|
|
1538
|
+
const grandTotal = { ready: 0, running: 0, done: 0, dead: 0, total: 0 };
|
|
1539
|
+
for (const item of listRes.queues) {
|
|
1540
|
+
summary[item.queue] = item.stats;
|
|
1541
|
+
grandTotal.ready += item.stats.ready;
|
|
1542
|
+
grandTotal.running += item.stats.running;
|
|
1543
|
+
grandTotal.done += item.stats.done;
|
|
1544
|
+
grandTotal.dead += item.stats.dead;
|
|
1545
|
+
grandTotal.total += item.stats.total;
|
|
1546
|
+
}
|
|
1547
|
+
return { queues: summary, grandTotal };
|
|
1548
|
+
}
|
|
1549
|
+
case "workmatic_list_jobs": {
|
|
1550
|
+
const queue = args.queue;
|
|
1551
|
+
const status = args.status;
|
|
1552
|
+
const limit = Math.min(Math.max(Number(args.limit ?? 20), 1), 100);
|
|
1553
|
+
const offset = Math.max(Number(args.offset ?? 0), 0);
|
|
1554
|
+
let query = db.selectFrom("workmatic_jobs").select([
|
|
1555
|
+
"id",
|
|
1556
|
+
"public_id",
|
|
1557
|
+
"queue",
|
|
1558
|
+
"status",
|
|
1559
|
+
"priority",
|
|
1560
|
+
"payload",
|
|
1561
|
+
"attempts",
|
|
1562
|
+
"max_attempts",
|
|
1563
|
+
"run_at",
|
|
1564
|
+
"created_at",
|
|
1565
|
+
"updated_at",
|
|
1566
|
+
"last_error"
|
|
1567
|
+
]);
|
|
1568
|
+
if (queue) {
|
|
1569
|
+
query = query.where("queue", "=", queue);
|
|
1570
|
+
}
|
|
1571
|
+
if (status) {
|
|
1572
|
+
query = query.where("status", "=", status);
|
|
1573
|
+
}
|
|
1574
|
+
const rows = await query.orderBy("priority", "asc").orderBy("id", "asc").limit(limit).offset(offset).execute();
|
|
1575
|
+
const jobs = rows.map((r) => {
|
|
1576
|
+
let parsedPayload;
|
|
1577
|
+
try {
|
|
1578
|
+
parsedPayload = JSON.parse(r.payload);
|
|
1579
|
+
} catch {
|
|
1580
|
+
parsedPayload = r.payload;
|
|
1581
|
+
}
|
|
1582
|
+
return {
|
|
1583
|
+
id: r.id,
|
|
1584
|
+
publicId: r.public_id,
|
|
1585
|
+
queue: r.queue,
|
|
1586
|
+
status: r.status,
|
|
1587
|
+
priority: r.priority,
|
|
1588
|
+
attempts: r.attempts,
|
|
1589
|
+
maxAttempts: r.max_attempts,
|
|
1590
|
+
runAt: r.run_at,
|
|
1591
|
+
createdAt: r.created_at,
|
|
1592
|
+
updatedAt: r.updated_at,
|
|
1593
|
+
lastError: r.last_error,
|
|
1594
|
+
payload: parsedPayload
|
|
1595
|
+
};
|
|
1596
|
+
});
|
|
1597
|
+
return { jobs, count: jobs.length, limit, offset };
|
|
1598
|
+
}
|
|
1599
|
+
case "workmatic_get_dead_jobs": {
|
|
1600
|
+
const queue = args.queue;
|
|
1601
|
+
const limit = Math.min(Math.max(Number(args.limit ?? 20), 1), 100);
|
|
1602
|
+
return executeTool(db, "workmatic_list_jobs", {
|
|
1603
|
+
queue,
|
|
1604
|
+
status: "dead",
|
|
1605
|
+
limit
|
|
1606
|
+
});
|
|
1607
|
+
}
|
|
1608
|
+
case "workmatic_add_job": {
|
|
1609
|
+
const queue = args.queue || "default";
|
|
1610
|
+
const payload = args.payload;
|
|
1611
|
+
const priority = args.priority !== void 0 ? Number(args.priority) : 0;
|
|
1612
|
+
const delayMs = args.delayMs !== void 0 ? Number(args.delayMs) : 0;
|
|
1613
|
+
const maxAttempts = args.maxAttempts !== void 0 ? Number(args.maxAttempts) : 3;
|
|
1614
|
+
const client = createClient({ db, queue });
|
|
1615
|
+
const result = await client.add(payload, { priority, delayMs, maxAttempts });
|
|
1616
|
+
return { ok: true, id: result.id, queue };
|
|
1617
|
+
}
|
|
1618
|
+
case "workmatic_retry_job": {
|
|
1619
|
+
const publicId = args.publicId;
|
|
1620
|
+
if (!publicId) {
|
|
1621
|
+
throw new Error("publicId is required");
|
|
1622
|
+
}
|
|
1623
|
+
const job = await db.selectFrom("workmatic_jobs").select(["queue", "status"]).where("public_id", "=", publicId).executeTakeFirst();
|
|
1624
|
+
if (!job) {
|
|
1625
|
+
throw new Error(`Job not found: ${publicId}`);
|
|
1626
|
+
}
|
|
1627
|
+
const timestamp = now();
|
|
1628
|
+
await db.updateTable("workmatic_jobs").set({
|
|
1629
|
+
status: "ready",
|
|
1630
|
+
attempts: 0,
|
|
1631
|
+
lease_until: 0,
|
|
1632
|
+
last_error: null,
|
|
1633
|
+
run_at: timestamp,
|
|
1634
|
+
updated_at: timestamp
|
|
1635
|
+
}).where("public_id", "=", publicId).execute();
|
|
1636
|
+
context?.onJobStatusChanged?.({
|
|
1637
|
+
publicId,
|
|
1638
|
+
queue: job.queue,
|
|
1639
|
+
previousStatus: job.status,
|
|
1640
|
+
status: "ready",
|
|
1641
|
+
timestamp,
|
|
1642
|
+
error: null
|
|
1643
|
+
});
|
|
1644
|
+
return { ok: true, id: publicId, message: `Job ${publicId} reset to ready` };
|
|
1645
|
+
}
|
|
1646
|
+
case "workmatic_retry_all_dead": {
|
|
1647
|
+
const queue = args.queue;
|
|
1648
|
+
const timestamp = now();
|
|
1649
|
+
let query = db.updateTable("workmatic_jobs").set({
|
|
1650
|
+
status: "ready",
|
|
1651
|
+
attempts: 0,
|
|
1652
|
+
lease_until: 0,
|
|
1653
|
+
last_error: null,
|
|
1654
|
+
run_at: timestamp,
|
|
1655
|
+
updated_at: timestamp
|
|
1656
|
+
}).where("status", "=", "dead");
|
|
1657
|
+
if (queue) {
|
|
1658
|
+
query = query.where("queue", "=", queue);
|
|
1659
|
+
}
|
|
1660
|
+
const res = await query.execute();
|
|
1661
|
+
const retriedCount = Number(res[0].numUpdatedRows);
|
|
1662
|
+
if (retriedCount > 0) {
|
|
1663
|
+
context?.onJobStatusChanged?.({
|
|
1664
|
+
publicId: "*",
|
|
1665
|
+
queue: queue ?? "*",
|
|
1666
|
+
previousStatus: "dead",
|
|
1667
|
+
status: "ready",
|
|
1668
|
+
timestamp,
|
|
1669
|
+
error: null
|
|
1670
|
+
});
|
|
1671
|
+
}
|
|
1672
|
+
return { ok: true, retriedCount, queue: queue ?? "all" };
|
|
1673
|
+
}
|
|
1674
|
+
case "workmatic_update_job_status": {
|
|
1675
|
+
const publicId = args.publicId;
|
|
1676
|
+
const status = args.status;
|
|
1677
|
+
if (!publicId) {
|
|
1678
|
+
throw new Error("publicId is required");
|
|
1679
|
+
}
|
|
1680
|
+
if (!status || !["ready", "done", "dead"].includes(status)) {
|
|
1681
|
+
throw new Error("status is required and must be 'ready', 'done', or 'dead'");
|
|
1682
|
+
}
|
|
1683
|
+
const job = await db.selectFrom("workmatic_jobs").select(["id", "public_id", "queue", "status", "attempts"]).where("public_id", "=", publicId).executeTakeFirst();
|
|
1684
|
+
if (!job) {
|
|
1685
|
+
throw new Error(`Job not found: ${publicId}`);
|
|
1686
|
+
}
|
|
1687
|
+
if (job.status === status) {
|
|
1688
|
+
return {
|
|
1689
|
+
ok: true,
|
|
1690
|
+
id: publicId,
|
|
1691
|
+
queue: job.queue,
|
|
1692
|
+
status,
|
|
1693
|
+
unchanged: true
|
|
1694
|
+
};
|
|
1695
|
+
}
|
|
1696
|
+
const timestamp = now();
|
|
1697
|
+
const previousStatus = job.status;
|
|
1698
|
+
const delayMs = Math.max(Number(args.delayMs ?? 0), 0);
|
|
1699
|
+
const resetAttempts = args.resetAttempts !== void 0 ? Boolean(args.resetAttempts) : status === "ready";
|
|
1700
|
+
const updateData = {
|
|
1701
|
+
status,
|
|
1702
|
+
updated_at: timestamp,
|
|
1703
|
+
lease_until: 0
|
|
1704
|
+
};
|
|
1705
|
+
if (status === "ready") {
|
|
1706
|
+
updateData.run_at = timestamp + delayMs;
|
|
1707
|
+
}
|
|
1708
|
+
if (resetAttempts) {
|
|
1709
|
+
updateData.attempts = 0;
|
|
1710
|
+
}
|
|
1711
|
+
if (args.error !== void 0) {
|
|
1712
|
+
updateData.last_error = args.error;
|
|
1713
|
+
} else if (status === "ready") {
|
|
1714
|
+
updateData.last_error = null;
|
|
1715
|
+
}
|
|
1716
|
+
await db.updateTable("workmatic_jobs").set(updateData).where("public_id", "=", publicId).execute();
|
|
1717
|
+
const event = {
|
|
1718
|
+
publicId,
|
|
1719
|
+
queue: job.queue,
|
|
1720
|
+
previousStatus,
|
|
1721
|
+
status,
|
|
1722
|
+
timestamp,
|
|
1723
|
+
error: updateData.last_error ?? null
|
|
1724
|
+
};
|
|
1725
|
+
context?.onJobStatusChanged?.(event);
|
|
1726
|
+
return {
|
|
1727
|
+
ok: true,
|
|
1728
|
+
id: publicId,
|
|
1729
|
+
queue: job.queue,
|
|
1730
|
+
previousStatus,
|
|
1731
|
+
status,
|
|
1732
|
+
signaled: true
|
|
1733
|
+
};
|
|
1734
|
+
}
|
|
1735
|
+
case "workmatic_pause_queue": {
|
|
1736
|
+
const queue = args.queue;
|
|
1737
|
+
if (!queue) {
|
|
1738
|
+
throw new Error("queue is required");
|
|
1739
|
+
}
|
|
1740
|
+
const timestamp = now();
|
|
1741
|
+
await sql4`
|
|
1742
|
+
INSERT INTO workmatic_settings (queue, paused, updated_at)
|
|
1743
|
+
VALUES (${queue}, 1, ${timestamp})
|
|
1744
|
+
ON CONFLICT(queue) DO UPDATE SET
|
|
1745
|
+
paused = 1,
|
|
1746
|
+
updated_at = ${timestamp}
|
|
1747
|
+
`.execute(db);
|
|
1748
|
+
return { ok: true, queue, paused: true };
|
|
1749
|
+
}
|
|
1750
|
+
case "workmatic_resume_queue": {
|
|
1751
|
+
const queue = args.queue;
|
|
1752
|
+
if (!queue) {
|
|
1753
|
+
throw new Error("queue is required");
|
|
1754
|
+
}
|
|
1755
|
+
const timestamp = now();
|
|
1756
|
+
await sql4`
|
|
1757
|
+
INSERT INTO workmatic_settings (queue, paused, updated_at)
|
|
1758
|
+
VALUES (${queue}, 0, ${timestamp})
|
|
1759
|
+
ON CONFLICT(queue) DO UPDATE SET
|
|
1760
|
+
paused = 0,
|
|
1761
|
+
updated_at = ${timestamp}
|
|
1762
|
+
`.execute(db);
|
|
1763
|
+
return { ok: true, queue, paused: false };
|
|
1764
|
+
}
|
|
1765
|
+
case "workmatic_purge_jobs": {
|
|
1766
|
+
const queue = args.queue;
|
|
1767
|
+
const status = args.status || "done";
|
|
1768
|
+
let query = db.deleteFrom("workmatic_jobs");
|
|
1769
|
+
if (queue) {
|
|
1770
|
+
query = query.where("queue", "=", queue);
|
|
1771
|
+
}
|
|
1772
|
+
if (status !== "all") {
|
|
1773
|
+
query = query.where("status", "=", status);
|
|
1774
|
+
}
|
|
1775
|
+
const res = await query.execute();
|
|
1776
|
+
const deletedCount = Number(res[0].numDeletedRows);
|
|
1777
|
+
return { ok: true, deletedCount, queue: queue ?? "all", status };
|
|
1778
|
+
}
|
|
1779
|
+
case "workmatic_transfer_jobs": {
|
|
1780
|
+
const fromQueue = args.fromQueue;
|
|
1781
|
+
const toQueue = args.toQueue;
|
|
1782
|
+
if (!fromQueue || !toQueue) {
|
|
1783
|
+
throw new Error("fromQueue and toQueue are required");
|
|
1784
|
+
}
|
|
1785
|
+
if (fromQueue === toQueue) {
|
|
1786
|
+
return { ok: true, moved: 0 };
|
|
1787
|
+
}
|
|
1788
|
+
const status = args.status || "ready";
|
|
1789
|
+
const limit = Math.max(Number(args.limit ?? 1e3), 1);
|
|
1790
|
+
const resetForRetry = Boolean(args.resetForRetry);
|
|
1791
|
+
const orch = createOrchestrator({ db });
|
|
1792
|
+
const result = await orch.transfer({
|
|
1793
|
+
from: fromQueue,
|
|
1794
|
+
to: toQueue,
|
|
1795
|
+
status,
|
|
1796
|
+
limit,
|
|
1797
|
+
resetForRetry
|
|
1798
|
+
});
|
|
1799
|
+
return {
|
|
1800
|
+
ok: true,
|
|
1801
|
+
moved: result.moved,
|
|
1802
|
+
fromQueue,
|
|
1803
|
+
toQueue,
|
|
1804
|
+
status: resetForRetry && status === "dead" ? "ready" : status
|
|
1805
|
+
};
|
|
1806
|
+
}
|
|
1807
|
+
default:
|
|
1808
|
+
throw new Error(`Unknown tool: ${name}`);
|
|
1809
|
+
}
|
|
1810
|
+
}
|
|
1811
|
+
|
|
1812
|
+
// src/mcp/server.ts
|
|
1813
|
+
function createMcpServer(options) {
|
|
1814
|
+
const db = options.db;
|
|
1815
|
+
const input = options.input ?? process.stdin;
|
|
1816
|
+
const output = options.output ?? process.stdout;
|
|
1817
|
+
const emitter = new EventEmitter();
|
|
1818
|
+
let rl = null;
|
|
1819
|
+
let isRunning = false;
|
|
1820
|
+
function notifyJobStatusChanged(event) {
|
|
1821
|
+
if (options.onJobStatusChanged) {
|
|
1822
|
+
options.onJobStatusChanged(event);
|
|
1823
|
+
}
|
|
1824
|
+
emitter.emit("jobStatusChanged", event);
|
|
1825
|
+
if (event.status === "ready") {
|
|
1826
|
+
if (options.orchestrator) {
|
|
1827
|
+
if (event.queue === "*") {
|
|
1828
|
+
for (const worker of options.orchestrator.workers()) {
|
|
1829
|
+
worker.wakeUp();
|
|
1830
|
+
}
|
|
1831
|
+
} else {
|
|
1832
|
+
try {
|
|
1833
|
+
options.orchestrator.worker(event.queue).wakeUp();
|
|
1834
|
+
} catch {
|
|
1835
|
+
}
|
|
1836
|
+
}
|
|
1837
|
+
}
|
|
1838
|
+
if (options.workers) {
|
|
1839
|
+
for (const worker of options.workers) {
|
|
1840
|
+
if (event.queue === "*" || worker.queue === event.queue) {
|
|
1841
|
+
worker.wakeUp();
|
|
1842
|
+
}
|
|
1843
|
+
}
|
|
1844
|
+
}
|
|
1845
|
+
}
|
|
1846
|
+
if (isRunning) {
|
|
1847
|
+
output.write(
|
|
1848
|
+
JSON.stringify({
|
|
1849
|
+
jsonrpc: "2.0",
|
|
1850
|
+
method: "notifications/workmatic/job_status_changed",
|
|
1851
|
+
params: event
|
|
1852
|
+
}) + "\n"
|
|
1853
|
+
);
|
|
1854
|
+
}
|
|
1855
|
+
}
|
|
1856
|
+
async function handleMessage(raw) {
|
|
1857
|
+
const trimmed = raw.trim();
|
|
1858
|
+
if (!trimmed) {
|
|
1859
|
+
return null;
|
|
1860
|
+
}
|
|
1861
|
+
let msg;
|
|
1862
|
+
try {
|
|
1863
|
+
msg = JSON.parse(trimmed);
|
|
1864
|
+
} catch {
|
|
1865
|
+
return JSON.stringify({
|
|
1866
|
+
jsonrpc: "2.0",
|
|
1867
|
+
id: null,
|
|
1868
|
+
error: {
|
|
1869
|
+
code: -32700,
|
|
1870
|
+
message: "Parse error"
|
|
1871
|
+
}
|
|
1872
|
+
});
|
|
1873
|
+
}
|
|
1874
|
+
if (!msg || typeof msg !== "object" || msg.jsonrpc !== "2.0" || typeof msg.method !== "string") {
|
|
1875
|
+
return JSON.stringify({
|
|
1876
|
+
jsonrpc: "2.0",
|
|
1877
|
+
id: msg?.id ?? null,
|
|
1878
|
+
error: {
|
|
1879
|
+
code: -32600,
|
|
1880
|
+
message: "Invalid Request"
|
|
1881
|
+
}
|
|
1882
|
+
});
|
|
1883
|
+
}
|
|
1884
|
+
const isNotification = msg.id === void 0;
|
|
1885
|
+
switch (msg.method) {
|
|
1886
|
+
case "initialize": {
|
|
1887
|
+
const result = {
|
|
1888
|
+
protocolVersion: "2024-11-05",
|
|
1889
|
+
capabilities: {
|
|
1890
|
+
tools: {}
|
|
1891
|
+
},
|
|
1892
|
+
serverInfo: {
|
|
1893
|
+
name: "workmatic-mcp",
|
|
1894
|
+
version: "0.1.0"
|
|
1895
|
+
}
|
|
1896
|
+
};
|
|
1897
|
+
return isNotification ? null : JSON.stringify({ jsonrpc: "2.0", id: msg.id, result });
|
|
1898
|
+
}
|
|
1899
|
+
case "notifications/initialized": {
|
|
1900
|
+
return null;
|
|
1901
|
+
}
|
|
1902
|
+
case "ping": {
|
|
1903
|
+
return isNotification ? null : JSON.stringify({ jsonrpc: "2.0", id: msg.id, result: {} });
|
|
1904
|
+
}
|
|
1905
|
+
case "tools/list": {
|
|
1906
|
+
const result = {
|
|
1907
|
+
tools: MCP_TOOL_DEFINITIONS
|
|
1908
|
+
};
|
|
1909
|
+
return isNotification ? null : JSON.stringify({ jsonrpc: "2.0", id: msg.id, result });
|
|
1910
|
+
}
|
|
1911
|
+
case "tools/call": {
|
|
1912
|
+
if (!msg.params || typeof msg.params.name !== "string") {
|
|
1913
|
+
return isNotification ? null : JSON.stringify({
|
|
1914
|
+
jsonrpc: "2.0",
|
|
1915
|
+
id: msg.id,
|
|
1916
|
+
error: {
|
|
1917
|
+
code: -32602,
|
|
1918
|
+
message: 'Invalid params: tool "name" is required'
|
|
1919
|
+
}
|
|
1920
|
+
});
|
|
1921
|
+
}
|
|
1922
|
+
const toolName = msg.params.name;
|
|
1923
|
+
const toolArgs = msg.params.arguments ?? {};
|
|
1924
|
+
try {
|
|
1925
|
+
const toolResult = await executeTool(db, toolName, toolArgs, {
|
|
1926
|
+
onJobStatusChanged: notifyJobStatusChanged
|
|
1927
|
+
});
|
|
1928
|
+
const response = {
|
|
1929
|
+
content: [
|
|
1930
|
+
{
|
|
1931
|
+
type: "text",
|
|
1932
|
+
text: JSON.stringify(toolResult, null, 2)
|
|
1933
|
+
}
|
|
1934
|
+
]
|
|
1935
|
+
};
|
|
1936
|
+
return isNotification ? null : JSON.stringify({ jsonrpc: "2.0", id: msg.id, result: response });
|
|
1937
|
+
} catch (err) {
|
|
1938
|
+
const errorMessage = err instanceof Error ? err.message : String(err);
|
|
1939
|
+
const response = {
|
|
1940
|
+
content: [
|
|
1941
|
+
{
|
|
1942
|
+
type: "text",
|
|
1943
|
+
text: errorMessage
|
|
1944
|
+
}
|
|
1945
|
+
],
|
|
1946
|
+
isError: true
|
|
1947
|
+
};
|
|
1948
|
+
return isNotification ? null : JSON.stringify({ jsonrpc: "2.0", id: msg.id, result: response });
|
|
1949
|
+
}
|
|
1950
|
+
}
|
|
1951
|
+
default: {
|
|
1952
|
+
return isNotification ? null : JSON.stringify({
|
|
1953
|
+
jsonrpc: "2.0",
|
|
1954
|
+
id: msg.id,
|
|
1955
|
+
error: {
|
|
1956
|
+
code: -32601,
|
|
1957
|
+
message: `Method not found: ${msg.method}`
|
|
1958
|
+
}
|
|
1959
|
+
});
|
|
1960
|
+
}
|
|
1961
|
+
}
|
|
1962
|
+
}
|
|
1963
|
+
const server = {
|
|
1964
|
+
start() {
|
|
1965
|
+
if (isRunning) return;
|
|
1966
|
+
isRunning = true;
|
|
1967
|
+
rl = readline.createInterface({
|
|
1968
|
+
input,
|
|
1969
|
+
terminal: false
|
|
1970
|
+
});
|
|
1971
|
+
rl.on("line", (line) => {
|
|
1972
|
+
void handleMessage(line).then((response) => {
|
|
1973
|
+
if (response && isRunning) {
|
|
1974
|
+
output.write(response + "\n");
|
|
1975
|
+
}
|
|
1976
|
+
});
|
|
1977
|
+
});
|
|
1978
|
+
},
|
|
1979
|
+
stop() {
|
|
1980
|
+
if (!isRunning) return;
|
|
1981
|
+
isRunning = false;
|
|
1982
|
+
rl.close();
|
|
1983
|
+
rl = null;
|
|
1984
|
+
},
|
|
1985
|
+
handleMessage,
|
|
1986
|
+
on(event, listener) {
|
|
1987
|
+
emitter.on(event, listener);
|
|
1988
|
+
return server;
|
|
1989
|
+
},
|
|
1990
|
+
off(event, listener) {
|
|
1991
|
+
emitter.off(event, listener);
|
|
1992
|
+
return server;
|
|
1993
|
+
}
|
|
1994
|
+
};
|
|
1995
|
+
return server;
|
|
1996
|
+
}
|
|
1043
1997
|
export {
|
|
1044
1998
|
DEFAULT_WORKER_TIMEOUT_MS,
|
|
1999
|
+
MCP_TOOL_DEFINITIONS,
|
|
2000
|
+
attachGracefulShutdown,
|
|
1045
2001
|
createClient,
|
|
1046
2002
|
createDashboard,
|
|
1047
2003
|
createDashboardMiddleware,
|
|
1048
2004
|
createDatabase,
|
|
2005
|
+
createMcpServer,
|
|
1049
2006
|
createOrchestrator,
|
|
1050
2007
|
createWorker,
|
|
1051
2008
|
defaultBackoff,
|
|
2009
|
+
enableStatementCache,
|
|
2010
|
+
executeTool,
|
|
1052
2011
|
getUnderlyingDb,
|
|
1053
2012
|
validatePayload
|
|
1054
2013
|
};
|