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/cli.cjs
CHANGED
|
@@ -45,6 +45,11 @@ function createDatabase(options = {}) {
|
|
|
45
45
|
sqliteDb.pragma("journal_mode = WAL");
|
|
46
46
|
sqliteDb.pragma("synchronous = NORMAL");
|
|
47
47
|
sqliteDb.pragma("busy_timeout = 5000");
|
|
48
|
+
sqliteDb.pragma("cache_size = -64000");
|
|
49
|
+
sqliteDb.pragma("temp_store = MEMORY");
|
|
50
|
+
sqliteDb.pragma("mmap_size = 268435456");
|
|
51
|
+
const cacheSize = options.statementCacheSize ?? 1e3;
|
|
52
|
+
enableStatementCache(sqliteDb, cacheSize);
|
|
48
53
|
const db = new import_kysely.Kysely({
|
|
49
54
|
dialect: new import_kysely.SqliteDialect({
|
|
50
55
|
database: sqliteDb
|
|
@@ -72,13 +77,23 @@ function createSchema(db) {
|
|
|
72
77
|
last_error TEXT
|
|
73
78
|
)
|
|
74
79
|
`);
|
|
80
|
+
ensurePartialIndex(
|
|
81
|
+
db,
|
|
82
|
+
"idx_workmatic_jobs_claim",
|
|
83
|
+
`CREATE INDEX IF NOT EXISTS idx_workmatic_jobs_claim
|
|
84
|
+
ON workmatic_jobs (queue, status, run_at, priority, id)
|
|
85
|
+
WHERE status = 'ready'`
|
|
86
|
+
);
|
|
87
|
+
ensurePartialIndex(
|
|
88
|
+
db,
|
|
89
|
+
"idx_workmatic_jobs_lease",
|
|
90
|
+
`CREATE INDEX IF NOT EXISTS idx_workmatic_jobs_lease
|
|
91
|
+
ON workmatic_jobs (status, lease_until)
|
|
92
|
+
WHERE status = 'running'`
|
|
93
|
+
);
|
|
75
94
|
db.exec(`
|
|
76
|
-
CREATE INDEX IF NOT EXISTS
|
|
77
|
-
ON workmatic_jobs (queue, status
|
|
78
|
-
`);
|
|
79
|
-
db.exec(`
|
|
80
|
-
CREATE INDEX IF NOT EXISTS idx_workmatic_jobs_lease
|
|
81
|
-
ON workmatic_jobs (status, lease_until)
|
|
95
|
+
CREATE INDEX IF NOT EXISTS idx_workmatic_jobs_queue_status
|
|
96
|
+
ON workmatic_jobs (queue, status)
|
|
82
97
|
`);
|
|
83
98
|
db.exec(`
|
|
84
99
|
CREATE TABLE IF NOT EXISTS workmatic_settings (
|
|
@@ -91,6 +106,46 @@ function createSchema(db) {
|
|
|
91
106
|
UPDATE workmatic_jobs SET status = 'dead' WHERE status = 'failed'
|
|
92
107
|
`);
|
|
93
108
|
}
|
|
109
|
+
function ensurePartialIndex(db, indexName, createSql) {
|
|
110
|
+
const row = db.prepare(
|
|
111
|
+
"SELECT sql FROM sqlite_master WHERE type = 'index' AND name = ?"
|
|
112
|
+
).get(indexName);
|
|
113
|
+
if (row?.sql && !row.sql.toUpperCase().includes("WHERE")) {
|
|
114
|
+
db.exec(`DROP INDEX IF EXISTS ${indexName}`);
|
|
115
|
+
}
|
|
116
|
+
db.exec(createSql);
|
|
117
|
+
}
|
|
118
|
+
function enableStatementCache(db, maxStatements = 1e3) {
|
|
119
|
+
if (maxStatements <= 0) {
|
|
120
|
+
return db;
|
|
121
|
+
}
|
|
122
|
+
const originalPrepare = db.prepare.bind(db);
|
|
123
|
+
const cache = /* @__PURE__ */ new Map();
|
|
124
|
+
db.prepare = function(sql4) {
|
|
125
|
+
const cached = cache.get(sql4);
|
|
126
|
+
if (cached) {
|
|
127
|
+
cache.delete(sql4);
|
|
128
|
+
cache.set(sql4, cached);
|
|
129
|
+
if (!cached.busy) {
|
|
130
|
+
return cached;
|
|
131
|
+
}
|
|
132
|
+
return originalPrepare(sql4);
|
|
133
|
+
}
|
|
134
|
+
const stmt = originalPrepare(sql4);
|
|
135
|
+
if (cache.size >= maxStatements) {
|
|
136
|
+
const oldestKey = cache.keys().next().value;
|
|
137
|
+
cache.delete(oldestKey);
|
|
138
|
+
}
|
|
139
|
+
cache.set(sql4, stmt);
|
|
140
|
+
return stmt;
|
|
141
|
+
};
|
|
142
|
+
const originalClose = db.close.bind(db);
|
|
143
|
+
db.close = function() {
|
|
144
|
+
cache.clear();
|
|
145
|
+
return originalClose();
|
|
146
|
+
};
|
|
147
|
+
return db;
|
|
148
|
+
}
|
|
94
149
|
|
|
95
150
|
// src/orchestrator.ts
|
|
96
151
|
var import_kysely4 = require("kysely");
|
|
@@ -127,10 +182,16 @@ function now() {
|
|
|
127
182
|
|
|
128
183
|
// src/client.ts
|
|
129
184
|
function createClient(options) {
|
|
130
|
-
const { db, queue = "default" } = options;
|
|
185
|
+
const { db, queue = "default", onJobAdded, worker } = options;
|
|
131
186
|
if (!db) {
|
|
132
187
|
throw new Error("Database instance is required");
|
|
133
188
|
}
|
|
189
|
+
function notifyJobAdded(delayMs) {
|
|
190
|
+
if (delayMs <= 0) {
|
|
191
|
+
worker?.wakeUp();
|
|
192
|
+
onJobAdded?.();
|
|
193
|
+
}
|
|
194
|
+
}
|
|
134
195
|
return {
|
|
135
196
|
/**
|
|
136
197
|
* Add a job to the queue
|
|
@@ -145,20 +206,13 @@ function createClient(options) {
|
|
|
145
206
|
const publicId = (0, import_nanoid.nanoid)();
|
|
146
207
|
const timestamp = now();
|
|
147
208
|
const runAt = timestamp + delayMs;
|
|
148
|
-
await db.
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
attempts: 0,
|
|
156
|
-
max_attempts: maxAttempts,
|
|
157
|
-
lease_until: 0,
|
|
158
|
-
created_at: timestamp,
|
|
159
|
-
updated_at: timestamp,
|
|
160
|
-
last_error: null
|
|
161
|
-
}).execute();
|
|
209
|
+
await db.executeQuery(
|
|
210
|
+
import_kysely2.CompiledQuery.raw(
|
|
211
|
+
`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)`,
|
|
212
|
+
[publicId, queue, payloadJson, priority, runAt, maxAttempts, timestamp, timestamp]
|
|
213
|
+
)
|
|
214
|
+
);
|
|
215
|
+
notifyJobAdded(delayMs);
|
|
162
216
|
return { ok: true, id: publicId };
|
|
163
217
|
},
|
|
164
218
|
async addMany(payloads, opts = {}) {
|
|
@@ -172,7 +226,7 @@ function createClient(options) {
|
|
|
172
226
|
}
|
|
173
227
|
const timestamp = now();
|
|
174
228
|
const runAt = timestamp + delayMs;
|
|
175
|
-
|
|
229
|
+
const result = await db.transaction().execute(async (trx) => {
|
|
176
230
|
const ids = [];
|
|
177
231
|
const rows = payloads.map((payload) => {
|
|
178
232
|
const payloadJson = validatePayload(payload);
|
|
@@ -196,15 +250,19 @@ function createClient(options) {
|
|
|
196
250
|
await trx.insertInto("workmatic_jobs").values(rows).execute();
|
|
197
251
|
return { ok: true, ids };
|
|
198
252
|
});
|
|
253
|
+
notifyJobAdded(delayMs);
|
|
254
|
+
return result;
|
|
199
255
|
},
|
|
200
256
|
/**
|
|
201
257
|
* Get job statistics for the queue
|
|
202
258
|
*/
|
|
203
259
|
async stats() {
|
|
204
|
-
const result = await db.
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
260
|
+
const result = await db.executeQuery(
|
|
261
|
+
import_kysely2.CompiledQuery.raw(
|
|
262
|
+
"SELECT status, count(*) AS count FROM workmatic_jobs WHERE queue = ? GROUP BY status",
|
|
263
|
+
[queue]
|
|
264
|
+
)
|
|
265
|
+
);
|
|
208
266
|
const stats = {
|
|
209
267
|
ready: 0,
|
|
210
268
|
running: 0,
|
|
@@ -212,7 +270,7 @@ function createClient(options) {
|
|
|
212
270
|
dead: 0,
|
|
213
271
|
total: 0
|
|
214
272
|
};
|
|
215
|
-
for (const row of result) {
|
|
273
|
+
for (const row of result.rows) {
|
|
216
274
|
const status = row.status;
|
|
217
275
|
const count = Number(row.count);
|
|
218
276
|
if (status in stats) {
|
|
@@ -239,6 +297,76 @@ function createClient(options) {
|
|
|
239
297
|
// src/worker.ts
|
|
240
298
|
var import_fastq = __toESM(require("fastq"), 1);
|
|
241
299
|
var import_kysely3 = require("kysely");
|
|
300
|
+
|
|
301
|
+
// src/shutdown.ts
|
|
302
|
+
function attachGracefulShutdown(target, options = {}) {
|
|
303
|
+
const {
|
|
304
|
+
signals = ["SIGINT", "SIGTERM"],
|
|
305
|
+
timeoutMs = 3e4,
|
|
306
|
+
exitOnComplete = true,
|
|
307
|
+
exitCode = 0,
|
|
308
|
+
timeoutExitCode = 1,
|
|
309
|
+
onShutdownStart,
|
|
310
|
+
onShutdownComplete,
|
|
311
|
+
onShutdownError
|
|
312
|
+
} = options;
|
|
313
|
+
let shuttingDown = false;
|
|
314
|
+
async function stopTarget() {
|
|
315
|
+
if (Array.isArray(target)) {
|
|
316
|
+
await Promise.all(target.map((w) => w.stop()));
|
|
317
|
+
} else if ("stopAll" in target) {
|
|
318
|
+
await target.stopAll();
|
|
319
|
+
} else {
|
|
320
|
+
await target.stop();
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
const handler = async (signal) => {
|
|
324
|
+
if (shuttingDown) {
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
shuttingDown = true;
|
|
328
|
+
onShutdownStart?.(signal);
|
|
329
|
+
let timer = null;
|
|
330
|
+
if (timeoutMs > 0) {
|
|
331
|
+
timer = setTimeout(() => {
|
|
332
|
+
const err = new Error(`Graceful shutdown timed out after ${timeoutMs}ms`);
|
|
333
|
+
onShutdownError?.(err);
|
|
334
|
+
if (exitOnComplete) {
|
|
335
|
+
process.exit(timeoutExitCode);
|
|
336
|
+
}
|
|
337
|
+
}, timeoutMs);
|
|
338
|
+
timer.unref();
|
|
339
|
+
}
|
|
340
|
+
try {
|
|
341
|
+
await stopTarget();
|
|
342
|
+
if (timer) {
|
|
343
|
+
clearTimeout(timer);
|
|
344
|
+
}
|
|
345
|
+
onShutdownComplete?.();
|
|
346
|
+
if (exitOnComplete) {
|
|
347
|
+
process.exit(exitCode);
|
|
348
|
+
}
|
|
349
|
+
} catch (err) {
|
|
350
|
+
if (timer) {
|
|
351
|
+
clearTimeout(timer);
|
|
352
|
+
}
|
|
353
|
+
onShutdownError?.(err);
|
|
354
|
+
if (exitOnComplete) {
|
|
355
|
+
process.exit(timeoutExitCode);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
};
|
|
359
|
+
for (const sig of signals) {
|
|
360
|
+
process.on(sig, handler);
|
|
361
|
+
}
|
|
362
|
+
return function detach() {
|
|
363
|
+
for (const sig of signals) {
|
|
364
|
+
process.removeListener(sig, handler);
|
|
365
|
+
}
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// src/worker.ts
|
|
242
370
|
var DEFAULT_WORKER_TIMEOUT_MS = 6e4;
|
|
243
371
|
function parseClaimedRows(result) {
|
|
244
372
|
const rows = result.rows;
|
|
@@ -276,7 +404,8 @@ function createWorker(options) {
|
|
|
276
404
|
autoRestore = true,
|
|
277
405
|
pauseCheckIntervalMs = 300,
|
|
278
406
|
requeueExpiredIntervalMs = 0,
|
|
279
|
-
onPumpError
|
|
407
|
+
onPumpError,
|
|
408
|
+
completionBatchSize = 50
|
|
280
409
|
} = options;
|
|
281
410
|
if (!db) {
|
|
282
411
|
throw new Error("Database instance is required");
|
|
@@ -289,6 +418,8 @@ function createWorker(options) {
|
|
|
289
418
|
let lastPauseCheckAt = 0;
|
|
290
419
|
let cachedDbPaused = false;
|
|
291
420
|
let lastRequeueAt = 0;
|
|
421
|
+
let pendingDone = [];
|
|
422
|
+
let flushTimeout = null;
|
|
292
423
|
function notifyPumpError(error) {
|
|
293
424
|
console.error("[workmatic] Pump error:", error);
|
|
294
425
|
onPumpError?.(error);
|
|
@@ -300,13 +431,16 @@ function createWorker(options) {
|
|
|
300
431
|
if (!persistState) return;
|
|
301
432
|
const timestamp = now();
|
|
302
433
|
const key = getStateKey();
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
434
|
+
try {
|
|
435
|
+
await import_kysely3.sql`
|
|
436
|
+
INSERT INTO workmatic_settings (queue, paused, updated_at)
|
|
437
|
+
VALUES (${key}, ${state === "paused" ? 1 : state === "running" ? 2 : 0}, ${timestamp})
|
|
438
|
+
ON CONFLICT(queue) DO UPDATE SET
|
|
439
|
+
paused = ${state === "paused" ? 1 : state === "running" ? 2 : 0},
|
|
440
|
+
updated_at = ${timestamp}
|
|
441
|
+
`.execute(db);
|
|
442
|
+
} catch {
|
|
443
|
+
}
|
|
310
444
|
}
|
|
311
445
|
async function loadState() {
|
|
312
446
|
if (!persistState) return null;
|
|
@@ -333,52 +467,104 @@ function createWorker(options) {
|
|
|
333
467
|
async function claimBatch(limit) {
|
|
334
468
|
const timestamp = now();
|
|
335
469
|
const leaseUntil = timestamp + leaseMs;
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
470
|
+
const result = await import_kysely3.sql`
|
|
471
|
+
UPDATE workmatic_jobs
|
|
472
|
+
SET status = 'running', lease_until = ${leaseUntil}, updated_at = ${timestamp}
|
|
473
|
+
WHERE rowid IN (
|
|
474
|
+
SELECT rowid FROM workmatic_jobs
|
|
475
|
+
WHERE queue = ${queue}
|
|
476
|
+
AND status = 'ready'
|
|
477
|
+
AND run_at <= ${timestamp}
|
|
478
|
+
ORDER BY priority ASC, id ASC
|
|
479
|
+
LIMIT ${limit}
|
|
480
|
+
)
|
|
481
|
+
RETURNING id, public_id, queue, payload, attempts, max_attempts, priority, created_at, last_error
|
|
482
|
+
`.execute(db);
|
|
483
|
+
return parseClaimedRows(result);
|
|
484
|
+
}
|
|
485
|
+
async function flushDoneBatch() {
|
|
486
|
+
if (flushTimeout !== null) {
|
|
487
|
+
clearImmediate(flushTimeout);
|
|
488
|
+
flushTimeout = null;
|
|
489
|
+
}
|
|
490
|
+
if (pendingDone.length === 0) {
|
|
491
|
+
return;
|
|
492
|
+
}
|
|
493
|
+
const current = pendingDone;
|
|
494
|
+
pendingDone = [];
|
|
495
|
+
const timestamp = now();
|
|
496
|
+
try {
|
|
497
|
+
if (current.length === 1) {
|
|
498
|
+
await db.executeQuery(
|
|
499
|
+
import_kysely3.CompiledQuery.raw(
|
|
500
|
+
"UPDATE workmatic_jobs SET status = 'done', lease_until = 0, updated_at = ? WHERE id = ?",
|
|
501
|
+
[timestamp, current[0].id]
|
|
502
|
+
)
|
|
503
|
+
);
|
|
504
|
+
} else {
|
|
505
|
+
const CHUNK_SIZE = 500;
|
|
506
|
+
for (let i = 0; i < current.length; i += CHUNK_SIZE) {
|
|
507
|
+
const chunk = current.slice(i, i + CHUNK_SIZE);
|
|
508
|
+
const placeholders = chunk.map(() => "?").join(", ");
|
|
509
|
+
const params = [timestamp, ...chunk.map((item) => item.id)];
|
|
510
|
+
await db.executeQuery(
|
|
511
|
+
import_kysely3.CompiledQuery.raw(
|
|
512
|
+
`UPDATE workmatic_jobs SET status = 'done', lease_until = 0, updated_at = ? WHERE id IN (${placeholders})`,
|
|
513
|
+
params
|
|
514
|
+
)
|
|
515
|
+
);
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
for (const item of current) {
|
|
519
|
+
item.resolve();
|
|
520
|
+
}
|
|
521
|
+
} catch (err) {
|
|
522
|
+
for (const item of current) {
|
|
523
|
+
item.reject(err);
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
function markDone(jobId) {
|
|
528
|
+
if (completionBatchSize <= 0) {
|
|
529
|
+
return db.executeQuery(
|
|
530
|
+
import_kysely3.CompiledQuery.raw(
|
|
531
|
+
"UPDATE workmatic_jobs SET status = 'done', lease_until = 0, updated_at = ? WHERE id = ?",
|
|
532
|
+
[now(), jobId]
|
|
347
533
|
)
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
534
|
+
).then(() => {
|
|
535
|
+
});
|
|
536
|
+
}
|
|
537
|
+
return new Promise((resolve, reject) => {
|
|
538
|
+
pendingDone.push({ id: jobId, resolve, reject });
|
|
539
|
+
if (pendingDone.length >= completionBatchSize) {
|
|
540
|
+
void flushDoneBatch();
|
|
541
|
+
} else if (!flushTimeout) {
|
|
542
|
+
flushTimeout = setImmediate(() => {
|
|
543
|
+
flushTimeout = null;
|
|
544
|
+
void flushDoneBatch();
|
|
545
|
+
});
|
|
546
|
+
}
|
|
351
547
|
});
|
|
352
548
|
}
|
|
353
|
-
async function markDone(jobId) {
|
|
354
|
-
await db.updateTable("workmatic_jobs").set({
|
|
355
|
-
status: "done",
|
|
356
|
-
lease_until: 0,
|
|
357
|
-
updated_at: now()
|
|
358
|
-
}).where("id", "=", jobId).execute();
|
|
359
|
-
}
|
|
360
549
|
async function markFailed(jobId, attempts, maxAttempts, error) {
|
|
361
550
|
const timestamp = now();
|
|
362
551
|
const newAttempts = attempts + 1;
|
|
363
552
|
const errorMessage = error.message || String(error);
|
|
364
553
|
if (newAttempts < maxAttempts) {
|
|
365
554
|
const runAt = timestamp + backoff(newAttempts);
|
|
366
|
-
await db.
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
updated_at: timestamp
|
|
373
|
-
}).where("id", "=", jobId).execute();
|
|
555
|
+
await db.executeQuery(
|
|
556
|
+
import_kysely3.CompiledQuery.raw(
|
|
557
|
+
"UPDATE workmatic_jobs SET status = 'ready', attempts = ?, run_at = ?, lease_until = 0, last_error = ?, updated_at = ? WHERE id = ?",
|
|
558
|
+
[newAttempts, runAt, errorMessage, timestamp, jobId]
|
|
559
|
+
)
|
|
560
|
+
);
|
|
374
561
|
} else {
|
|
375
|
-
await db.
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
}).where("id", "=", jobId).execute();
|
|
562
|
+
await db.executeQuery(
|
|
563
|
+
import_kysely3.CompiledQuery.raw(
|
|
564
|
+
"UPDATE workmatic_jobs SET status = 'dead', attempts = ?, lease_until = 0, last_error = ?, updated_at = ? WHERE id = ?",
|
|
565
|
+
[newAttempts, errorMessage, timestamp, jobId]
|
|
566
|
+
)
|
|
567
|
+
);
|
|
382
568
|
}
|
|
383
569
|
}
|
|
384
570
|
async function withTimeout(promise, ms, jobId) {
|
|
@@ -425,8 +611,13 @@ function createWorker(options) {
|
|
|
425
611
|
}
|
|
426
612
|
}
|
|
427
613
|
async function isQueuePausedInDb() {
|
|
428
|
-
const
|
|
429
|
-
|
|
614
|
+
const result = await db.executeQuery(
|
|
615
|
+
import_kysely3.CompiledQuery.raw(
|
|
616
|
+
"SELECT paused FROM workmatic_settings WHERE queue = ? LIMIT 1",
|
|
617
|
+
[queue]
|
|
618
|
+
)
|
|
619
|
+
);
|
|
620
|
+
return result.rows[0]?.paused === 1;
|
|
430
621
|
}
|
|
431
622
|
async function pump() {
|
|
432
623
|
if (!running) {
|
|
@@ -481,8 +672,7 @@ function createWorker(options) {
|
|
|
481
672
|
running = true;
|
|
482
673
|
paused = false;
|
|
483
674
|
fastqQueue = import_fastq.default.promise(processJob, concurrency);
|
|
484
|
-
saveState("running")
|
|
485
|
-
});
|
|
675
|
+
void saveState("running");
|
|
486
676
|
pump();
|
|
487
677
|
},
|
|
488
678
|
async stop() {
|
|
@@ -494,27 +684,28 @@ function createWorker(options) {
|
|
|
494
684
|
clearTimeout(pumpTimeout);
|
|
495
685
|
pumpTimeout = null;
|
|
496
686
|
}
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
}
|
|
687
|
+
await fastqQueue.drained();
|
|
688
|
+
fastqQueue = null;
|
|
689
|
+
await flushDoneBatch();
|
|
501
690
|
await saveState("stopped");
|
|
502
691
|
},
|
|
503
692
|
pause() {
|
|
504
693
|
paused = true;
|
|
505
|
-
|
|
506
|
-
|
|
694
|
+
void flushDoneBatch();
|
|
695
|
+
void saveState("paused");
|
|
507
696
|
},
|
|
508
697
|
resume() {
|
|
509
698
|
paused = false;
|
|
510
|
-
saveState("running")
|
|
511
|
-
});
|
|
699
|
+
void saveState("running");
|
|
512
700
|
},
|
|
513
701
|
async stats() {
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
import_kysely3.
|
|
517
|
-
|
|
702
|
+
await flushDoneBatch();
|
|
703
|
+
const result = await db.executeQuery(
|
|
704
|
+
import_kysely3.CompiledQuery.raw(
|
|
705
|
+
"SELECT status, count(*) AS count FROM workmatic_jobs WHERE queue = ? GROUP BY status",
|
|
706
|
+
[queue]
|
|
707
|
+
)
|
|
708
|
+
);
|
|
518
709
|
const stats = {
|
|
519
710
|
ready: 0,
|
|
520
711
|
running: 0,
|
|
@@ -522,7 +713,7 @@ function createWorker(options) {
|
|
|
522
713
|
dead: 0,
|
|
523
714
|
total: 0
|
|
524
715
|
};
|
|
525
|
-
for (const row of result) {
|
|
716
|
+
for (const row of result.rows) {
|
|
526
717
|
const status = row.status;
|
|
527
718
|
const count = Number(row.count);
|
|
528
719
|
if (status in stats) {
|
|
@@ -558,6 +749,22 @@ function createWorker(options) {
|
|
|
558
749
|
}
|
|
559
750
|
const result = await query.execute();
|
|
560
751
|
return Number(result[0]?.numDeletedRows ?? 0);
|
|
752
|
+
},
|
|
753
|
+
wakeUp() {
|
|
754
|
+
if (!running || paused) {
|
|
755
|
+
return;
|
|
756
|
+
}
|
|
757
|
+
if (pumpTimeout) {
|
|
758
|
+
clearTimeout(pumpTimeout);
|
|
759
|
+
pumpTimeout = null;
|
|
760
|
+
}
|
|
761
|
+
pumpTimeout = setTimeout(pump, 0);
|
|
762
|
+
},
|
|
763
|
+
async flushCompletions() {
|
|
764
|
+
await flushDoneBatch();
|
|
765
|
+
},
|
|
766
|
+
attachSignalHandlers(options2) {
|
|
767
|
+
return attachGracefulShutdown(this, options2);
|
|
561
768
|
}
|
|
562
769
|
};
|
|
563
770
|
if (persistState && autoRestore) {
|
|
@@ -590,7 +797,15 @@ function createOrchestrator(options) {
|
|
|
590
797
|
function ensureEntry(queue) {
|
|
591
798
|
let entry = registry.get(queue);
|
|
592
799
|
if (!entry) {
|
|
593
|
-
entry = {
|
|
800
|
+
entry = {
|
|
801
|
+
client: createClient({
|
|
802
|
+
db,
|
|
803
|
+
queue,
|
|
804
|
+
onJobAdded: () => {
|
|
805
|
+
registry.get(queue)?.worker?.wakeUp();
|
|
806
|
+
}
|
|
807
|
+
})
|
|
808
|
+
};
|
|
594
809
|
registry.set(queue, entry);
|
|
595
810
|
}
|
|
596
811
|
return entry;
|
|
@@ -763,11 +978,750 @@ function createOrchestrator(options) {
|
|
|
763
978
|
return;
|
|
764
979
|
}
|
|
765
980
|
await db.updateTable("workmatic_jobs").set({ queue: toQueue, updated_at: timestamp }).where("public_id", "=", publicId).execute();
|
|
981
|
+
},
|
|
982
|
+
attachSignalHandlers(options2) {
|
|
983
|
+
return attachGracefulShutdown(this, options2);
|
|
766
984
|
}
|
|
767
985
|
};
|
|
768
986
|
return orchestrator;
|
|
769
987
|
}
|
|
770
988
|
|
|
989
|
+
// src/mcp/server.ts
|
|
990
|
+
var import_node_readline = __toESM(require("readline"), 1);
|
|
991
|
+
var import_node_events = require("events");
|
|
992
|
+
|
|
993
|
+
// src/mcp/tools.ts
|
|
994
|
+
var import_kysely5 = require("kysely");
|
|
995
|
+
var MCP_TOOL_DEFINITIONS = [
|
|
996
|
+
{
|
|
997
|
+
name: "workmatic_list_queues",
|
|
998
|
+
description: "List all queues present in the Workmatic database along with job counts",
|
|
999
|
+
inputSchema: {
|
|
1000
|
+
type: "object",
|
|
1001
|
+
properties: {}
|
|
1002
|
+
}
|
|
1003
|
+
},
|
|
1004
|
+
{
|
|
1005
|
+
name: "workmatic_get_stats",
|
|
1006
|
+
description: "Get real-time job counts (ready, running, done, dead, total) for a specific queue or all queues",
|
|
1007
|
+
inputSchema: {
|
|
1008
|
+
type: "object",
|
|
1009
|
+
properties: {
|
|
1010
|
+
queue: {
|
|
1011
|
+
type: "string",
|
|
1012
|
+
description: "Optional queue name. If omitted, stats for all queues will be returned."
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
},
|
|
1017
|
+
{
|
|
1018
|
+
name: "workmatic_list_jobs",
|
|
1019
|
+
description: "List jobs in the database filtered by queue, status, and limit",
|
|
1020
|
+
inputSchema: {
|
|
1021
|
+
type: "object",
|
|
1022
|
+
properties: {
|
|
1023
|
+
queue: {
|
|
1024
|
+
type: "string",
|
|
1025
|
+
description: "Filter by queue name"
|
|
1026
|
+
},
|
|
1027
|
+
status: {
|
|
1028
|
+
type: "string",
|
|
1029
|
+
enum: ["ready", "running", "done", "dead"],
|
|
1030
|
+
description: "Filter by job status"
|
|
1031
|
+
},
|
|
1032
|
+
limit: {
|
|
1033
|
+
type: "number",
|
|
1034
|
+
description: "Maximum number of jobs to return (default: 20, max: 100)"
|
|
1035
|
+
},
|
|
1036
|
+
offset: {
|
|
1037
|
+
type: "number",
|
|
1038
|
+
description: "Number of jobs to skip for pagination (default: 0)"
|
|
1039
|
+
}
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
},
|
|
1043
|
+
{
|
|
1044
|
+
name: "workmatic_get_dead_jobs",
|
|
1045
|
+
description: "Retrieve failed/dead jobs with error details and payloads for debugging",
|
|
1046
|
+
inputSchema: {
|
|
1047
|
+
type: "object",
|
|
1048
|
+
properties: {
|
|
1049
|
+
queue: {
|
|
1050
|
+
type: "string",
|
|
1051
|
+
description: "Filter dead jobs by queue name"
|
|
1052
|
+
},
|
|
1053
|
+
limit: {
|
|
1054
|
+
type: "number",
|
|
1055
|
+
description: "Maximum number of dead jobs to return (default: 20)"
|
|
1056
|
+
}
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
},
|
|
1060
|
+
{
|
|
1061
|
+
name: "workmatic_add_job",
|
|
1062
|
+
description: "Enqueue a new background job into Workmatic",
|
|
1063
|
+
inputSchema: {
|
|
1064
|
+
type: "object",
|
|
1065
|
+
properties: {
|
|
1066
|
+
queue: {
|
|
1067
|
+
type: "string",
|
|
1068
|
+
description: 'Target queue name (default: "default")'
|
|
1069
|
+
},
|
|
1070
|
+
payload: {
|
|
1071
|
+
description: "Job payload (JSON object, string, number, etc.)"
|
|
1072
|
+
},
|
|
1073
|
+
priority: {
|
|
1074
|
+
type: "number",
|
|
1075
|
+
description: "Job priority (lower number = higher priority, default: 0)"
|
|
1076
|
+
},
|
|
1077
|
+
delayMs: {
|
|
1078
|
+
type: "number",
|
|
1079
|
+
description: "Delay in milliseconds before job can run (default: 0)"
|
|
1080
|
+
},
|
|
1081
|
+
maxAttempts: {
|
|
1082
|
+
type: "number",
|
|
1083
|
+
description: "Maximum execution retry attempts (default: 3)"
|
|
1084
|
+
}
|
|
1085
|
+
},
|
|
1086
|
+
required: ["payload"]
|
|
1087
|
+
}
|
|
1088
|
+
},
|
|
1089
|
+
{
|
|
1090
|
+
name: "workmatic_retry_job",
|
|
1091
|
+
description: "Retry a specific dead or failed job by resetting it to ready status",
|
|
1092
|
+
inputSchema: {
|
|
1093
|
+
type: "object",
|
|
1094
|
+
properties: {
|
|
1095
|
+
publicId: {
|
|
1096
|
+
type: "string",
|
|
1097
|
+
description: "Public ID of the job to retry"
|
|
1098
|
+
}
|
|
1099
|
+
},
|
|
1100
|
+
required: ["publicId"]
|
|
1101
|
+
}
|
|
1102
|
+
},
|
|
1103
|
+
{
|
|
1104
|
+
name: "workmatic_retry_all_dead",
|
|
1105
|
+
description: "Retry all dead jobs (optionally in a specific queue) by resetting them to ready status",
|
|
1106
|
+
inputSchema: {
|
|
1107
|
+
type: "object",
|
|
1108
|
+
properties: {
|
|
1109
|
+
queue: {
|
|
1110
|
+
type: "string",
|
|
1111
|
+
description: "Optional queue name to restrict retrying"
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
}
|
|
1115
|
+
},
|
|
1116
|
+
{
|
|
1117
|
+
name: "workmatic_pause_queue",
|
|
1118
|
+
description: "Pause a queue so workers stop claiming new jobs from it",
|
|
1119
|
+
inputSchema: {
|
|
1120
|
+
type: "object",
|
|
1121
|
+
properties: {
|
|
1122
|
+
queue: {
|
|
1123
|
+
type: "string",
|
|
1124
|
+
description: "Queue name to pause"
|
|
1125
|
+
}
|
|
1126
|
+
},
|
|
1127
|
+
required: ["queue"]
|
|
1128
|
+
}
|
|
1129
|
+
},
|
|
1130
|
+
{
|
|
1131
|
+
name: "workmatic_resume_queue",
|
|
1132
|
+
description: "Resume a paused queue so workers resume claiming jobs",
|
|
1133
|
+
inputSchema: {
|
|
1134
|
+
type: "object",
|
|
1135
|
+
properties: {
|
|
1136
|
+
queue: {
|
|
1137
|
+
type: "string",
|
|
1138
|
+
description: "Queue name to resume"
|
|
1139
|
+
}
|
|
1140
|
+
},
|
|
1141
|
+
required: ["queue"]
|
|
1142
|
+
}
|
|
1143
|
+
},
|
|
1144
|
+
{
|
|
1145
|
+
name: "workmatic_purge_jobs",
|
|
1146
|
+
description: "Permanently remove done or dead jobs from the database",
|
|
1147
|
+
inputSchema: {
|
|
1148
|
+
type: "object",
|
|
1149
|
+
properties: {
|
|
1150
|
+
queue: {
|
|
1151
|
+
type: "string",
|
|
1152
|
+
description: "Queue name to purge jobs from (optional)"
|
|
1153
|
+
},
|
|
1154
|
+
status: {
|
|
1155
|
+
type: "string",
|
|
1156
|
+
enum: ["done", "dead", "all"],
|
|
1157
|
+
description: 'Status of jobs to purge (default: "done")'
|
|
1158
|
+
}
|
|
1159
|
+
}
|
|
1160
|
+
}
|
|
1161
|
+
},
|
|
1162
|
+
{
|
|
1163
|
+
name: "workmatic_transfer_jobs",
|
|
1164
|
+
description: "Move jobs from one queue to another (e.g. from dead-letter queue back to primary)",
|
|
1165
|
+
inputSchema: {
|
|
1166
|
+
type: "object",
|
|
1167
|
+
properties: {
|
|
1168
|
+
fromQueue: {
|
|
1169
|
+
type: "string",
|
|
1170
|
+
description: "Source queue name"
|
|
1171
|
+
},
|
|
1172
|
+
toQueue: {
|
|
1173
|
+
type: "string",
|
|
1174
|
+
description: "Destination queue name"
|
|
1175
|
+
},
|
|
1176
|
+
status: {
|
|
1177
|
+
type: "string",
|
|
1178
|
+
enum: ["ready", "dead"],
|
|
1179
|
+
description: 'Status of jobs to transfer (default: "ready")'
|
|
1180
|
+
},
|
|
1181
|
+
limit: {
|
|
1182
|
+
type: "number",
|
|
1183
|
+
description: "Maximum number of jobs to transfer (default: 1000)"
|
|
1184
|
+
},
|
|
1185
|
+
resetForRetry: {
|
|
1186
|
+
type: "boolean",
|
|
1187
|
+
description: "If transferring dead jobs, reset their status to ready (default: false)"
|
|
1188
|
+
}
|
|
1189
|
+
},
|
|
1190
|
+
required: ["fromQueue", "toQueue"]
|
|
1191
|
+
}
|
|
1192
|
+
},
|
|
1193
|
+
{
|
|
1194
|
+
name: "workmatic_update_job_status",
|
|
1195
|
+
description: "Update the status of a specific job (ready, done, dead) and signal workers / listeners",
|
|
1196
|
+
inputSchema: {
|
|
1197
|
+
type: "object",
|
|
1198
|
+
properties: {
|
|
1199
|
+
publicId: {
|
|
1200
|
+
type: "string",
|
|
1201
|
+
description: "Public ID of the job to update"
|
|
1202
|
+
},
|
|
1203
|
+
status: {
|
|
1204
|
+
type: "string",
|
|
1205
|
+
enum: ["ready", "done", "dead"],
|
|
1206
|
+
description: "New status for the job"
|
|
1207
|
+
},
|
|
1208
|
+
error: {
|
|
1209
|
+
type: "string",
|
|
1210
|
+
description: "Optional error message when marking as dead or recording failure details"
|
|
1211
|
+
},
|
|
1212
|
+
resetAttempts: {
|
|
1213
|
+
type: "boolean",
|
|
1214
|
+
description: "Whether to reset attempts to 0 (default: true if status is ready, false otherwise)"
|
|
1215
|
+
},
|
|
1216
|
+
delayMs: {
|
|
1217
|
+
type: "number",
|
|
1218
|
+
description: "Delay in milliseconds before the job becomes ready (default: 0)"
|
|
1219
|
+
}
|
|
1220
|
+
},
|
|
1221
|
+
required: ["publicId", "status"]
|
|
1222
|
+
}
|
|
1223
|
+
}
|
|
1224
|
+
];
|
|
1225
|
+
async function executeTool(db, name, args2 = {}, context) {
|
|
1226
|
+
switch (name) {
|
|
1227
|
+
case "workmatic_list_queues": {
|
|
1228
|
+
const qJobs = await db.selectFrom("workmatic_jobs").select("queue").distinct().execute();
|
|
1229
|
+
const qSettings = await db.selectFrom("workmatic_settings").select("queue").distinct().execute();
|
|
1230
|
+
const set = /* @__PURE__ */ new Set();
|
|
1231
|
+
for (const r of qJobs) set.add(r.queue);
|
|
1232
|
+
for (const r of qSettings) {
|
|
1233
|
+
if (!r.queue.startsWith("worker_state_")) {
|
|
1234
|
+
set.add(r.queue);
|
|
1235
|
+
}
|
|
1236
|
+
}
|
|
1237
|
+
const queueList = Array.from(set).sort();
|
|
1238
|
+
const result = [];
|
|
1239
|
+
for (const q of queueList) {
|
|
1240
|
+
const client = createClient({ db, queue: q });
|
|
1241
|
+
const stats = await client.stats();
|
|
1242
|
+
const setting = await db.selectFrom("workmatic_settings").select("paused").where("queue", "=", q).executeTakeFirst();
|
|
1243
|
+
result.push({
|
|
1244
|
+
queue: q,
|
|
1245
|
+
stats: {
|
|
1246
|
+
ready: stats.ready,
|
|
1247
|
+
running: stats.running,
|
|
1248
|
+
done: stats.done,
|
|
1249
|
+
dead: stats.dead,
|
|
1250
|
+
total: stats.total
|
|
1251
|
+
},
|
|
1252
|
+
isPaused: setting?.paused === 1
|
|
1253
|
+
});
|
|
1254
|
+
}
|
|
1255
|
+
return { queues: result, totalQueues: result.length };
|
|
1256
|
+
}
|
|
1257
|
+
case "workmatic_get_stats": {
|
|
1258
|
+
const queue = args2.queue;
|
|
1259
|
+
if (queue) {
|
|
1260
|
+
const client = createClient({ db, queue });
|
|
1261
|
+
return { queue, stats: await client.stats() };
|
|
1262
|
+
}
|
|
1263
|
+
const listRes = await executeTool(db, "workmatic_list_queues");
|
|
1264
|
+
const summary = {};
|
|
1265
|
+
const grandTotal = { ready: 0, running: 0, done: 0, dead: 0, total: 0 };
|
|
1266
|
+
for (const item of listRes.queues) {
|
|
1267
|
+
summary[item.queue] = item.stats;
|
|
1268
|
+
grandTotal.ready += item.stats.ready;
|
|
1269
|
+
grandTotal.running += item.stats.running;
|
|
1270
|
+
grandTotal.done += item.stats.done;
|
|
1271
|
+
grandTotal.dead += item.stats.dead;
|
|
1272
|
+
grandTotal.total += item.stats.total;
|
|
1273
|
+
}
|
|
1274
|
+
return { queues: summary, grandTotal };
|
|
1275
|
+
}
|
|
1276
|
+
case "workmatic_list_jobs": {
|
|
1277
|
+
const queue = args2.queue;
|
|
1278
|
+
const status = args2.status;
|
|
1279
|
+
const limit = Math.min(Math.max(Number(args2.limit ?? 20), 1), 100);
|
|
1280
|
+
const offset = Math.max(Number(args2.offset ?? 0), 0);
|
|
1281
|
+
let query = db.selectFrom("workmatic_jobs").select([
|
|
1282
|
+
"id",
|
|
1283
|
+
"public_id",
|
|
1284
|
+
"queue",
|
|
1285
|
+
"status",
|
|
1286
|
+
"priority",
|
|
1287
|
+
"payload",
|
|
1288
|
+
"attempts",
|
|
1289
|
+
"max_attempts",
|
|
1290
|
+
"run_at",
|
|
1291
|
+
"created_at",
|
|
1292
|
+
"updated_at",
|
|
1293
|
+
"last_error"
|
|
1294
|
+
]);
|
|
1295
|
+
if (queue) {
|
|
1296
|
+
query = query.where("queue", "=", queue);
|
|
1297
|
+
}
|
|
1298
|
+
if (status) {
|
|
1299
|
+
query = query.where("status", "=", status);
|
|
1300
|
+
}
|
|
1301
|
+
const rows = await query.orderBy("priority", "asc").orderBy("id", "asc").limit(limit).offset(offset).execute();
|
|
1302
|
+
const jobs = rows.map((r) => {
|
|
1303
|
+
let parsedPayload;
|
|
1304
|
+
try {
|
|
1305
|
+
parsedPayload = JSON.parse(r.payload);
|
|
1306
|
+
} catch {
|
|
1307
|
+
parsedPayload = r.payload;
|
|
1308
|
+
}
|
|
1309
|
+
return {
|
|
1310
|
+
id: r.id,
|
|
1311
|
+
publicId: r.public_id,
|
|
1312
|
+
queue: r.queue,
|
|
1313
|
+
status: r.status,
|
|
1314
|
+
priority: r.priority,
|
|
1315
|
+
attempts: r.attempts,
|
|
1316
|
+
maxAttempts: r.max_attempts,
|
|
1317
|
+
runAt: r.run_at,
|
|
1318
|
+
createdAt: r.created_at,
|
|
1319
|
+
updatedAt: r.updated_at,
|
|
1320
|
+
lastError: r.last_error,
|
|
1321
|
+
payload: parsedPayload
|
|
1322
|
+
};
|
|
1323
|
+
});
|
|
1324
|
+
return { jobs, count: jobs.length, limit, offset };
|
|
1325
|
+
}
|
|
1326
|
+
case "workmatic_get_dead_jobs": {
|
|
1327
|
+
const queue = args2.queue;
|
|
1328
|
+
const limit = Math.min(Math.max(Number(args2.limit ?? 20), 1), 100);
|
|
1329
|
+
return executeTool(db, "workmatic_list_jobs", {
|
|
1330
|
+
queue,
|
|
1331
|
+
status: "dead",
|
|
1332
|
+
limit
|
|
1333
|
+
});
|
|
1334
|
+
}
|
|
1335
|
+
case "workmatic_add_job": {
|
|
1336
|
+
const queue = args2.queue || "default";
|
|
1337
|
+
const payload = args2.payload;
|
|
1338
|
+
const priority = args2.priority !== void 0 ? Number(args2.priority) : 0;
|
|
1339
|
+
const delayMs = args2.delayMs !== void 0 ? Number(args2.delayMs) : 0;
|
|
1340
|
+
const maxAttempts = args2.maxAttempts !== void 0 ? Number(args2.maxAttempts) : 3;
|
|
1341
|
+
const client = createClient({ db, queue });
|
|
1342
|
+
const result = await client.add(payload, { priority, delayMs, maxAttempts });
|
|
1343
|
+
return { ok: true, id: result.id, queue };
|
|
1344
|
+
}
|
|
1345
|
+
case "workmatic_retry_job": {
|
|
1346
|
+
const publicId = args2.publicId;
|
|
1347
|
+
if (!publicId) {
|
|
1348
|
+
throw new Error("publicId is required");
|
|
1349
|
+
}
|
|
1350
|
+
const job = await db.selectFrom("workmatic_jobs").select(["queue", "status"]).where("public_id", "=", publicId).executeTakeFirst();
|
|
1351
|
+
if (!job) {
|
|
1352
|
+
throw new Error(`Job not found: ${publicId}`);
|
|
1353
|
+
}
|
|
1354
|
+
const timestamp = now();
|
|
1355
|
+
await db.updateTable("workmatic_jobs").set({
|
|
1356
|
+
status: "ready",
|
|
1357
|
+
attempts: 0,
|
|
1358
|
+
lease_until: 0,
|
|
1359
|
+
last_error: null,
|
|
1360
|
+
run_at: timestamp,
|
|
1361
|
+
updated_at: timestamp
|
|
1362
|
+
}).where("public_id", "=", publicId).execute();
|
|
1363
|
+
context?.onJobStatusChanged?.({
|
|
1364
|
+
publicId,
|
|
1365
|
+
queue: job.queue,
|
|
1366
|
+
previousStatus: job.status,
|
|
1367
|
+
status: "ready",
|
|
1368
|
+
timestamp,
|
|
1369
|
+
error: null
|
|
1370
|
+
});
|
|
1371
|
+
return { ok: true, id: publicId, message: `Job ${publicId} reset to ready` };
|
|
1372
|
+
}
|
|
1373
|
+
case "workmatic_retry_all_dead": {
|
|
1374
|
+
const queue = args2.queue;
|
|
1375
|
+
const timestamp = now();
|
|
1376
|
+
let query = db.updateTable("workmatic_jobs").set({
|
|
1377
|
+
status: "ready",
|
|
1378
|
+
attempts: 0,
|
|
1379
|
+
lease_until: 0,
|
|
1380
|
+
last_error: null,
|
|
1381
|
+
run_at: timestamp,
|
|
1382
|
+
updated_at: timestamp
|
|
1383
|
+
}).where("status", "=", "dead");
|
|
1384
|
+
if (queue) {
|
|
1385
|
+
query = query.where("queue", "=", queue);
|
|
1386
|
+
}
|
|
1387
|
+
const res = await query.execute();
|
|
1388
|
+
const retriedCount = Number(res[0].numUpdatedRows);
|
|
1389
|
+
if (retriedCount > 0) {
|
|
1390
|
+
context?.onJobStatusChanged?.({
|
|
1391
|
+
publicId: "*",
|
|
1392
|
+
queue: queue ?? "*",
|
|
1393
|
+
previousStatus: "dead",
|
|
1394
|
+
status: "ready",
|
|
1395
|
+
timestamp,
|
|
1396
|
+
error: null
|
|
1397
|
+
});
|
|
1398
|
+
}
|
|
1399
|
+
return { ok: true, retriedCount, queue: queue ?? "all" };
|
|
1400
|
+
}
|
|
1401
|
+
case "workmatic_update_job_status": {
|
|
1402
|
+
const publicId = args2.publicId;
|
|
1403
|
+
const status = args2.status;
|
|
1404
|
+
if (!publicId) {
|
|
1405
|
+
throw new Error("publicId is required");
|
|
1406
|
+
}
|
|
1407
|
+
if (!status || !["ready", "done", "dead"].includes(status)) {
|
|
1408
|
+
throw new Error("status is required and must be 'ready', 'done', or 'dead'");
|
|
1409
|
+
}
|
|
1410
|
+
const job = await db.selectFrom("workmatic_jobs").select(["id", "public_id", "queue", "status", "attempts"]).where("public_id", "=", publicId).executeTakeFirst();
|
|
1411
|
+
if (!job) {
|
|
1412
|
+
throw new Error(`Job not found: ${publicId}`);
|
|
1413
|
+
}
|
|
1414
|
+
if (job.status === status) {
|
|
1415
|
+
return {
|
|
1416
|
+
ok: true,
|
|
1417
|
+
id: publicId,
|
|
1418
|
+
queue: job.queue,
|
|
1419
|
+
status,
|
|
1420
|
+
unchanged: true
|
|
1421
|
+
};
|
|
1422
|
+
}
|
|
1423
|
+
const timestamp = now();
|
|
1424
|
+
const previousStatus = job.status;
|
|
1425
|
+
const delayMs = Math.max(Number(args2.delayMs ?? 0), 0);
|
|
1426
|
+
const resetAttempts = args2.resetAttempts !== void 0 ? Boolean(args2.resetAttempts) : status === "ready";
|
|
1427
|
+
const updateData = {
|
|
1428
|
+
status,
|
|
1429
|
+
updated_at: timestamp,
|
|
1430
|
+
lease_until: 0
|
|
1431
|
+
};
|
|
1432
|
+
if (status === "ready") {
|
|
1433
|
+
updateData.run_at = timestamp + delayMs;
|
|
1434
|
+
}
|
|
1435
|
+
if (resetAttempts) {
|
|
1436
|
+
updateData.attempts = 0;
|
|
1437
|
+
}
|
|
1438
|
+
if (args2.error !== void 0) {
|
|
1439
|
+
updateData.last_error = args2.error;
|
|
1440
|
+
} else if (status === "ready") {
|
|
1441
|
+
updateData.last_error = null;
|
|
1442
|
+
}
|
|
1443
|
+
await db.updateTable("workmatic_jobs").set(updateData).where("public_id", "=", publicId).execute();
|
|
1444
|
+
const event = {
|
|
1445
|
+
publicId,
|
|
1446
|
+
queue: job.queue,
|
|
1447
|
+
previousStatus,
|
|
1448
|
+
status,
|
|
1449
|
+
timestamp,
|
|
1450
|
+
error: updateData.last_error ?? null
|
|
1451
|
+
};
|
|
1452
|
+
context?.onJobStatusChanged?.(event);
|
|
1453
|
+
return {
|
|
1454
|
+
ok: true,
|
|
1455
|
+
id: publicId,
|
|
1456
|
+
queue: job.queue,
|
|
1457
|
+
previousStatus,
|
|
1458
|
+
status,
|
|
1459
|
+
signaled: true
|
|
1460
|
+
};
|
|
1461
|
+
}
|
|
1462
|
+
case "workmatic_pause_queue": {
|
|
1463
|
+
const queue = args2.queue;
|
|
1464
|
+
if (!queue) {
|
|
1465
|
+
throw new Error("queue is required");
|
|
1466
|
+
}
|
|
1467
|
+
const timestamp = now();
|
|
1468
|
+
await import_kysely5.sql`
|
|
1469
|
+
INSERT INTO workmatic_settings (queue, paused, updated_at)
|
|
1470
|
+
VALUES (${queue}, 1, ${timestamp})
|
|
1471
|
+
ON CONFLICT(queue) DO UPDATE SET
|
|
1472
|
+
paused = 1,
|
|
1473
|
+
updated_at = ${timestamp}
|
|
1474
|
+
`.execute(db);
|
|
1475
|
+
return { ok: true, queue, paused: true };
|
|
1476
|
+
}
|
|
1477
|
+
case "workmatic_resume_queue": {
|
|
1478
|
+
const queue = args2.queue;
|
|
1479
|
+
if (!queue) {
|
|
1480
|
+
throw new Error("queue is required");
|
|
1481
|
+
}
|
|
1482
|
+
const timestamp = now();
|
|
1483
|
+
await import_kysely5.sql`
|
|
1484
|
+
INSERT INTO workmatic_settings (queue, paused, updated_at)
|
|
1485
|
+
VALUES (${queue}, 0, ${timestamp})
|
|
1486
|
+
ON CONFLICT(queue) DO UPDATE SET
|
|
1487
|
+
paused = 0,
|
|
1488
|
+
updated_at = ${timestamp}
|
|
1489
|
+
`.execute(db);
|
|
1490
|
+
return { ok: true, queue, paused: false };
|
|
1491
|
+
}
|
|
1492
|
+
case "workmatic_purge_jobs": {
|
|
1493
|
+
const queue = args2.queue;
|
|
1494
|
+
const status = args2.status || "done";
|
|
1495
|
+
let query = db.deleteFrom("workmatic_jobs");
|
|
1496
|
+
if (queue) {
|
|
1497
|
+
query = query.where("queue", "=", queue);
|
|
1498
|
+
}
|
|
1499
|
+
if (status !== "all") {
|
|
1500
|
+
query = query.where("status", "=", status);
|
|
1501
|
+
}
|
|
1502
|
+
const res = await query.execute();
|
|
1503
|
+
const deletedCount = Number(res[0].numDeletedRows);
|
|
1504
|
+
return { ok: true, deletedCount, queue: queue ?? "all", status };
|
|
1505
|
+
}
|
|
1506
|
+
case "workmatic_transfer_jobs": {
|
|
1507
|
+
const fromQueue = args2.fromQueue;
|
|
1508
|
+
const toQueue = args2.toQueue;
|
|
1509
|
+
if (!fromQueue || !toQueue) {
|
|
1510
|
+
throw new Error("fromQueue and toQueue are required");
|
|
1511
|
+
}
|
|
1512
|
+
if (fromQueue === toQueue) {
|
|
1513
|
+
return { ok: true, moved: 0 };
|
|
1514
|
+
}
|
|
1515
|
+
const status = args2.status || "ready";
|
|
1516
|
+
const limit = Math.max(Number(args2.limit ?? 1e3), 1);
|
|
1517
|
+
const resetForRetry = Boolean(args2.resetForRetry);
|
|
1518
|
+
const orch = createOrchestrator({ db });
|
|
1519
|
+
const result = await orch.transfer({
|
|
1520
|
+
from: fromQueue,
|
|
1521
|
+
to: toQueue,
|
|
1522
|
+
status,
|
|
1523
|
+
limit,
|
|
1524
|
+
resetForRetry
|
|
1525
|
+
});
|
|
1526
|
+
return {
|
|
1527
|
+
ok: true,
|
|
1528
|
+
moved: result.moved,
|
|
1529
|
+
fromQueue,
|
|
1530
|
+
toQueue,
|
|
1531
|
+
status: resetForRetry && status === "dead" ? "ready" : status
|
|
1532
|
+
};
|
|
1533
|
+
}
|
|
1534
|
+
default:
|
|
1535
|
+
throw new Error(`Unknown tool: ${name}`);
|
|
1536
|
+
}
|
|
1537
|
+
}
|
|
1538
|
+
|
|
1539
|
+
// src/mcp/server.ts
|
|
1540
|
+
function createMcpServer(options) {
|
|
1541
|
+
const db = options.db;
|
|
1542
|
+
const input = options.input ?? process.stdin;
|
|
1543
|
+
const output = options.output ?? process.stdout;
|
|
1544
|
+
const emitter = new import_node_events.EventEmitter();
|
|
1545
|
+
let rl = null;
|
|
1546
|
+
let isRunning = false;
|
|
1547
|
+
function notifyJobStatusChanged(event) {
|
|
1548
|
+
if (options.onJobStatusChanged) {
|
|
1549
|
+
options.onJobStatusChanged(event);
|
|
1550
|
+
}
|
|
1551
|
+
emitter.emit("jobStatusChanged", event);
|
|
1552
|
+
if (event.status === "ready") {
|
|
1553
|
+
if (options.orchestrator) {
|
|
1554
|
+
if (event.queue === "*") {
|
|
1555
|
+
for (const worker of options.orchestrator.workers()) {
|
|
1556
|
+
worker.wakeUp();
|
|
1557
|
+
}
|
|
1558
|
+
} else {
|
|
1559
|
+
try {
|
|
1560
|
+
options.orchestrator.worker(event.queue).wakeUp();
|
|
1561
|
+
} catch {
|
|
1562
|
+
}
|
|
1563
|
+
}
|
|
1564
|
+
}
|
|
1565
|
+
if (options.workers) {
|
|
1566
|
+
for (const worker of options.workers) {
|
|
1567
|
+
if (event.queue === "*" || worker.queue === event.queue) {
|
|
1568
|
+
worker.wakeUp();
|
|
1569
|
+
}
|
|
1570
|
+
}
|
|
1571
|
+
}
|
|
1572
|
+
}
|
|
1573
|
+
if (isRunning) {
|
|
1574
|
+
output.write(
|
|
1575
|
+
JSON.stringify({
|
|
1576
|
+
jsonrpc: "2.0",
|
|
1577
|
+
method: "notifications/workmatic/job_status_changed",
|
|
1578
|
+
params: event
|
|
1579
|
+
}) + "\n"
|
|
1580
|
+
);
|
|
1581
|
+
}
|
|
1582
|
+
}
|
|
1583
|
+
async function handleMessage(raw) {
|
|
1584
|
+
const trimmed = raw.trim();
|
|
1585
|
+
if (!trimmed) {
|
|
1586
|
+
return null;
|
|
1587
|
+
}
|
|
1588
|
+
let msg;
|
|
1589
|
+
try {
|
|
1590
|
+
msg = JSON.parse(trimmed);
|
|
1591
|
+
} catch {
|
|
1592
|
+
return JSON.stringify({
|
|
1593
|
+
jsonrpc: "2.0",
|
|
1594
|
+
id: null,
|
|
1595
|
+
error: {
|
|
1596
|
+
code: -32700,
|
|
1597
|
+
message: "Parse error"
|
|
1598
|
+
}
|
|
1599
|
+
});
|
|
1600
|
+
}
|
|
1601
|
+
if (!msg || typeof msg !== "object" || msg.jsonrpc !== "2.0" || typeof msg.method !== "string") {
|
|
1602
|
+
return JSON.stringify({
|
|
1603
|
+
jsonrpc: "2.0",
|
|
1604
|
+
id: msg?.id ?? null,
|
|
1605
|
+
error: {
|
|
1606
|
+
code: -32600,
|
|
1607
|
+
message: "Invalid Request"
|
|
1608
|
+
}
|
|
1609
|
+
});
|
|
1610
|
+
}
|
|
1611
|
+
const isNotification = msg.id === void 0;
|
|
1612
|
+
switch (msg.method) {
|
|
1613
|
+
case "initialize": {
|
|
1614
|
+
const result = {
|
|
1615
|
+
protocolVersion: "2024-11-05",
|
|
1616
|
+
capabilities: {
|
|
1617
|
+
tools: {}
|
|
1618
|
+
},
|
|
1619
|
+
serverInfo: {
|
|
1620
|
+
name: "workmatic-mcp",
|
|
1621
|
+
version: "0.1.0"
|
|
1622
|
+
}
|
|
1623
|
+
};
|
|
1624
|
+
return isNotification ? null : JSON.stringify({ jsonrpc: "2.0", id: msg.id, result });
|
|
1625
|
+
}
|
|
1626
|
+
case "notifications/initialized": {
|
|
1627
|
+
return null;
|
|
1628
|
+
}
|
|
1629
|
+
case "ping": {
|
|
1630
|
+
return isNotification ? null : JSON.stringify({ jsonrpc: "2.0", id: msg.id, result: {} });
|
|
1631
|
+
}
|
|
1632
|
+
case "tools/list": {
|
|
1633
|
+
const result = {
|
|
1634
|
+
tools: MCP_TOOL_DEFINITIONS
|
|
1635
|
+
};
|
|
1636
|
+
return isNotification ? null : JSON.stringify({ jsonrpc: "2.0", id: msg.id, result });
|
|
1637
|
+
}
|
|
1638
|
+
case "tools/call": {
|
|
1639
|
+
if (!msg.params || typeof msg.params.name !== "string") {
|
|
1640
|
+
return isNotification ? null : JSON.stringify({
|
|
1641
|
+
jsonrpc: "2.0",
|
|
1642
|
+
id: msg.id,
|
|
1643
|
+
error: {
|
|
1644
|
+
code: -32602,
|
|
1645
|
+
message: 'Invalid params: tool "name" is required'
|
|
1646
|
+
}
|
|
1647
|
+
});
|
|
1648
|
+
}
|
|
1649
|
+
const toolName = msg.params.name;
|
|
1650
|
+
const toolArgs = msg.params.arguments ?? {};
|
|
1651
|
+
try {
|
|
1652
|
+
const toolResult = await executeTool(db, toolName, toolArgs, {
|
|
1653
|
+
onJobStatusChanged: notifyJobStatusChanged
|
|
1654
|
+
});
|
|
1655
|
+
const response = {
|
|
1656
|
+
content: [
|
|
1657
|
+
{
|
|
1658
|
+
type: "text",
|
|
1659
|
+
text: JSON.stringify(toolResult, null, 2)
|
|
1660
|
+
}
|
|
1661
|
+
]
|
|
1662
|
+
};
|
|
1663
|
+
return isNotification ? null : JSON.stringify({ jsonrpc: "2.0", id: msg.id, result: response });
|
|
1664
|
+
} catch (err) {
|
|
1665
|
+
const errorMessage = err instanceof Error ? err.message : String(err);
|
|
1666
|
+
const response = {
|
|
1667
|
+
content: [
|
|
1668
|
+
{
|
|
1669
|
+
type: "text",
|
|
1670
|
+
text: errorMessage
|
|
1671
|
+
}
|
|
1672
|
+
],
|
|
1673
|
+
isError: true
|
|
1674
|
+
};
|
|
1675
|
+
return isNotification ? null : JSON.stringify({ jsonrpc: "2.0", id: msg.id, result: response });
|
|
1676
|
+
}
|
|
1677
|
+
}
|
|
1678
|
+
default: {
|
|
1679
|
+
return isNotification ? null : JSON.stringify({
|
|
1680
|
+
jsonrpc: "2.0",
|
|
1681
|
+
id: msg.id,
|
|
1682
|
+
error: {
|
|
1683
|
+
code: -32601,
|
|
1684
|
+
message: `Method not found: ${msg.method}`
|
|
1685
|
+
}
|
|
1686
|
+
});
|
|
1687
|
+
}
|
|
1688
|
+
}
|
|
1689
|
+
}
|
|
1690
|
+
const server = {
|
|
1691
|
+
start() {
|
|
1692
|
+
if (isRunning) return;
|
|
1693
|
+
isRunning = true;
|
|
1694
|
+
rl = import_node_readline.default.createInterface({
|
|
1695
|
+
input,
|
|
1696
|
+
terminal: false
|
|
1697
|
+
});
|
|
1698
|
+
rl.on("line", (line) => {
|
|
1699
|
+
void handleMessage(line).then((response) => {
|
|
1700
|
+
if (response && isRunning) {
|
|
1701
|
+
output.write(response + "\n");
|
|
1702
|
+
}
|
|
1703
|
+
});
|
|
1704
|
+
});
|
|
1705
|
+
},
|
|
1706
|
+
stop() {
|
|
1707
|
+
if (!isRunning) return;
|
|
1708
|
+
isRunning = false;
|
|
1709
|
+
rl.close();
|
|
1710
|
+
rl = null;
|
|
1711
|
+
},
|
|
1712
|
+
handleMessage,
|
|
1713
|
+
on(event, listener) {
|
|
1714
|
+
emitter.on(event, listener);
|
|
1715
|
+
return server;
|
|
1716
|
+
},
|
|
1717
|
+
off(event, listener) {
|
|
1718
|
+
emitter.off(event, listener);
|
|
1719
|
+
return server;
|
|
1720
|
+
}
|
|
1721
|
+
};
|
|
1722
|
+
return server;
|
|
1723
|
+
}
|
|
1724
|
+
|
|
771
1725
|
// src/cli/handlers.ts
|
|
772
1726
|
function printUsage() {
|
|
773
1727
|
console.log(`
|
|
@@ -787,6 +1741,8 @@ Commands:
|
|
|
787
1741
|
pause <db> <queue> Pause a queue (running workers stop claiming)
|
|
788
1742
|
resume <db> <queue> Resume a paused queue
|
|
789
1743
|
transfer <db> <from> <to> Move jobs between queues
|
|
1744
|
+
mcp <db> Start Model Context Protocol (MCP) server
|
|
1745
|
+
|
|
790
1746
|
|
|
791
1747
|
Options:
|
|
792
1748
|
--status=<status> Filter by status (ready|running|done|dead), comma-separated for transfer
|
|
@@ -1260,6 +2216,34 @@ async function cmdTransfer(dbPath, from, to, options) {
|
|
|
1260
2216
|
await db.destroy();
|
|
1261
2217
|
}
|
|
1262
2218
|
}
|
|
2219
|
+
async function cmdMcp(dbPath, options = {}) {
|
|
2220
|
+
const db = createDatabase({ filename: dbPath });
|
|
2221
|
+
const server = createMcpServer({
|
|
2222
|
+
db,
|
|
2223
|
+
input: options.input,
|
|
2224
|
+
output: options.output
|
|
2225
|
+
});
|
|
2226
|
+
server.start();
|
|
2227
|
+
await new Promise((resolve) => {
|
|
2228
|
+
const cleanup = () => {
|
|
2229
|
+
server.stop();
|
|
2230
|
+
if (!options.input) {
|
|
2231
|
+
process.removeListener("SIGINT", cleanup);
|
|
2232
|
+
process.removeListener("SIGTERM", cleanup);
|
|
2233
|
+
process.stdin.removeListener("end", cleanup);
|
|
2234
|
+
}
|
|
2235
|
+
resolve();
|
|
2236
|
+
};
|
|
2237
|
+
if (options.input) {
|
|
2238
|
+
options.input.once("end", cleanup);
|
|
2239
|
+
} else {
|
|
2240
|
+
process.stdin.once("end", cleanup);
|
|
2241
|
+
process.once("SIGINT", cleanup);
|
|
2242
|
+
process.once("SIGTERM", cleanup);
|
|
2243
|
+
}
|
|
2244
|
+
});
|
|
2245
|
+
await db.destroy();
|
|
2246
|
+
}
|
|
1263
2247
|
async function runCommand(command2, dbPath, positionalArgs, options) {
|
|
1264
2248
|
switch (command2) {
|
|
1265
2249
|
case "stats":
|
|
@@ -1304,6 +2288,9 @@ async function runCommand(command2, dbPath, positionalArgs, options) {
|
|
|
1304
2288
|
}
|
|
1305
2289
|
await cmdTransfer(dbPath, positionalArgs[1], positionalArgs[2], options);
|
|
1306
2290
|
break;
|
|
2291
|
+
case "mcp":
|
|
2292
|
+
await cmdMcp(dbPath);
|
|
2293
|
+
break;
|
|
1307
2294
|
default:
|
|
1308
2295
|
throw new Error(`Unknown command: ${command2}`);
|
|
1309
2296
|
}
|
|
@@ -1322,7 +2309,8 @@ var KNOWN_COMMANDS = [
|
|
|
1322
2309
|
"pause",
|
|
1323
2310
|
"resume",
|
|
1324
2311
|
"queues",
|
|
1325
|
-
"transfer"
|
|
2312
|
+
"transfer",
|
|
2313
|
+
"mcp"
|
|
1326
2314
|
];
|
|
1327
2315
|
function isCliCommand(value) {
|
|
1328
2316
|
return KNOWN_COMMANDS.includes(value);
|