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.js
CHANGED
|
@@ -22,6 +22,11 @@ function createDatabase(options = {}) {
|
|
|
22
22
|
sqliteDb.pragma("journal_mode = WAL");
|
|
23
23
|
sqliteDb.pragma("synchronous = NORMAL");
|
|
24
24
|
sqliteDb.pragma("busy_timeout = 5000");
|
|
25
|
+
sqliteDb.pragma("cache_size = -64000");
|
|
26
|
+
sqliteDb.pragma("temp_store = MEMORY");
|
|
27
|
+
sqliteDb.pragma("mmap_size = 268435456");
|
|
28
|
+
const cacheSize = options.statementCacheSize ?? 1e3;
|
|
29
|
+
enableStatementCache(sqliteDb, cacheSize);
|
|
25
30
|
const db = new Kysely({
|
|
26
31
|
dialect: new SqliteDialect({
|
|
27
32
|
database: sqliteDb
|
|
@@ -49,13 +54,23 @@ function createSchema(db) {
|
|
|
49
54
|
last_error TEXT
|
|
50
55
|
)
|
|
51
56
|
`);
|
|
57
|
+
ensurePartialIndex(
|
|
58
|
+
db,
|
|
59
|
+
"idx_workmatic_jobs_claim",
|
|
60
|
+
`CREATE INDEX IF NOT EXISTS idx_workmatic_jobs_claim
|
|
61
|
+
ON workmatic_jobs (queue, status, run_at, priority, id)
|
|
62
|
+
WHERE status = 'ready'`
|
|
63
|
+
);
|
|
64
|
+
ensurePartialIndex(
|
|
65
|
+
db,
|
|
66
|
+
"idx_workmatic_jobs_lease",
|
|
67
|
+
`CREATE INDEX IF NOT EXISTS idx_workmatic_jobs_lease
|
|
68
|
+
ON workmatic_jobs (status, lease_until)
|
|
69
|
+
WHERE status = 'running'`
|
|
70
|
+
);
|
|
52
71
|
db.exec(`
|
|
53
|
-
CREATE INDEX IF NOT EXISTS
|
|
54
|
-
ON workmatic_jobs (queue, status
|
|
55
|
-
`);
|
|
56
|
-
db.exec(`
|
|
57
|
-
CREATE INDEX IF NOT EXISTS idx_workmatic_jobs_lease
|
|
58
|
-
ON workmatic_jobs (status, lease_until)
|
|
72
|
+
CREATE INDEX IF NOT EXISTS idx_workmatic_jobs_queue_status
|
|
73
|
+
ON workmatic_jobs (queue, status)
|
|
59
74
|
`);
|
|
60
75
|
db.exec(`
|
|
61
76
|
CREATE TABLE IF NOT EXISTS workmatic_settings (
|
|
@@ -68,13 +83,53 @@ function createSchema(db) {
|
|
|
68
83
|
UPDATE workmatic_jobs SET status = 'dead' WHERE status = 'failed'
|
|
69
84
|
`);
|
|
70
85
|
}
|
|
86
|
+
function ensurePartialIndex(db, indexName, createSql) {
|
|
87
|
+
const row = db.prepare(
|
|
88
|
+
"SELECT sql FROM sqlite_master WHERE type = 'index' AND name = ?"
|
|
89
|
+
).get(indexName);
|
|
90
|
+
if (row?.sql && !row.sql.toUpperCase().includes("WHERE")) {
|
|
91
|
+
db.exec(`DROP INDEX IF EXISTS ${indexName}`);
|
|
92
|
+
}
|
|
93
|
+
db.exec(createSql);
|
|
94
|
+
}
|
|
95
|
+
function enableStatementCache(db, maxStatements = 1e3) {
|
|
96
|
+
if (maxStatements <= 0) {
|
|
97
|
+
return db;
|
|
98
|
+
}
|
|
99
|
+
const originalPrepare = db.prepare.bind(db);
|
|
100
|
+
const cache = /* @__PURE__ */ new Map();
|
|
101
|
+
db.prepare = function(sql4) {
|
|
102
|
+
const cached = cache.get(sql4);
|
|
103
|
+
if (cached) {
|
|
104
|
+
cache.delete(sql4);
|
|
105
|
+
cache.set(sql4, cached);
|
|
106
|
+
if (!cached.busy) {
|
|
107
|
+
return cached;
|
|
108
|
+
}
|
|
109
|
+
return originalPrepare(sql4);
|
|
110
|
+
}
|
|
111
|
+
const stmt = originalPrepare(sql4);
|
|
112
|
+
if (cache.size >= maxStatements) {
|
|
113
|
+
const oldestKey = cache.keys().next().value;
|
|
114
|
+
cache.delete(oldestKey);
|
|
115
|
+
}
|
|
116
|
+
cache.set(sql4, stmt);
|
|
117
|
+
return stmt;
|
|
118
|
+
};
|
|
119
|
+
const originalClose = db.close.bind(db);
|
|
120
|
+
db.close = function() {
|
|
121
|
+
cache.clear();
|
|
122
|
+
return originalClose();
|
|
123
|
+
};
|
|
124
|
+
return db;
|
|
125
|
+
}
|
|
71
126
|
|
|
72
127
|
// src/orchestrator.ts
|
|
73
|
-
import { sql as
|
|
128
|
+
import { sql as sql2 } from "kysely";
|
|
74
129
|
|
|
75
130
|
// src/client.ts
|
|
76
131
|
import { nanoid } from "nanoid";
|
|
77
|
-
import {
|
|
132
|
+
import { CompiledQuery } from "kysely";
|
|
78
133
|
|
|
79
134
|
// src/utils.ts
|
|
80
135
|
var defaultBackoff = (attempts) => {
|
|
@@ -104,10 +159,16 @@ function now() {
|
|
|
104
159
|
|
|
105
160
|
// src/client.ts
|
|
106
161
|
function createClient(options) {
|
|
107
|
-
const { db, queue = "default" } = options;
|
|
162
|
+
const { db, queue = "default", onJobAdded, worker } = options;
|
|
108
163
|
if (!db) {
|
|
109
164
|
throw new Error("Database instance is required");
|
|
110
165
|
}
|
|
166
|
+
function notifyJobAdded(delayMs) {
|
|
167
|
+
if (delayMs <= 0) {
|
|
168
|
+
worker?.wakeUp();
|
|
169
|
+
onJobAdded?.();
|
|
170
|
+
}
|
|
171
|
+
}
|
|
111
172
|
return {
|
|
112
173
|
/**
|
|
113
174
|
* Add a job to the queue
|
|
@@ -122,20 +183,13 @@ function createClient(options) {
|
|
|
122
183
|
const publicId = nanoid();
|
|
123
184
|
const timestamp = now();
|
|
124
185
|
const runAt = timestamp + delayMs;
|
|
125
|
-
await db.
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
attempts: 0,
|
|
133
|
-
max_attempts: maxAttempts,
|
|
134
|
-
lease_until: 0,
|
|
135
|
-
created_at: timestamp,
|
|
136
|
-
updated_at: timestamp,
|
|
137
|
-
last_error: null
|
|
138
|
-
}).execute();
|
|
186
|
+
await db.executeQuery(
|
|
187
|
+
CompiledQuery.raw(
|
|
188
|
+
`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)`,
|
|
189
|
+
[publicId, queue, payloadJson, priority, runAt, maxAttempts, timestamp, timestamp]
|
|
190
|
+
)
|
|
191
|
+
);
|
|
192
|
+
notifyJobAdded(delayMs);
|
|
139
193
|
return { ok: true, id: publicId };
|
|
140
194
|
},
|
|
141
195
|
async addMany(payloads, opts = {}) {
|
|
@@ -149,7 +203,7 @@ function createClient(options) {
|
|
|
149
203
|
}
|
|
150
204
|
const timestamp = now();
|
|
151
205
|
const runAt = timestamp + delayMs;
|
|
152
|
-
|
|
206
|
+
const result = await db.transaction().execute(async (trx) => {
|
|
153
207
|
const ids = [];
|
|
154
208
|
const rows = payloads.map((payload) => {
|
|
155
209
|
const payloadJson = validatePayload(payload);
|
|
@@ -173,15 +227,19 @@ function createClient(options) {
|
|
|
173
227
|
await trx.insertInto("workmatic_jobs").values(rows).execute();
|
|
174
228
|
return { ok: true, ids };
|
|
175
229
|
});
|
|
230
|
+
notifyJobAdded(delayMs);
|
|
231
|
+
return result;
|
|
176
232
|
},
|
|
177
233
|
/**
|
|
178
234
|
* Get job statistics for the queue
|
|
179
235
|
*/
|
|
180
236
|
async stats() {
|
|
181
|
-
const result = await db.
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
237
|
+
const result = await db.executeQuery(
|
|
238
|
+
CompiledQuery.raw(
|
|
239
|
+
"SELECT status, count(*) AS count FROM workmatic_jobs WHERE queue = ? GROUP BY status",
|
|
240
|
+
[queue]
|
|
241
|
+
)
|
|
242
|
+
);
|
|
185
243
|
const stats = {
|
|
186
244
|
ready: 0,
|
|
187
245
|
running: 0,
|
|
@@ -189,7 +247,7 @@ function createClient(options) {
|
|
|
189
247
|
dead: 0,
|
|
190
248
|
total: 0
|
|
191
249
|
};
|
|
192
|
-
for (const row of result) {
|
|
250
|
+
for (const row of result.rows) {
|
|
193
251
|
const status = row.status;
|
|
194
252
|
const count = Number(row.count);
|
|
195
253
|
if (status in stats) {
|
|
@@ -215,7 +273,77 @@ function createClient(options) {
|
|
|
215
273
|
|
|
216
274
|
// src/worker.ts
|
|
217
275
|
import fastq from "fastq";
|
|
218
|
-
import { sql as
|
|
276
|
+
import { sql, CompiledQuery as CompiledQuery2 } from "kysely";
|
|
277
|
+
|
|
278
|
+
// src/shutdown.ts
|
|
279
|
+
function attachGracefulShutdown(target, options = {}) {
|
|
280
|
+
const {
|
|
281
|
+
signals = ["SIGINT", "SIGTERM"],
|
|
282
|
+
timeoutMs = 3e4,
|
|
283
|
+
exitOnComplete = true,
|
|
284
|
+
exitCode = 0,
|
|
285
|
+
timeoutExitCode = 1,
|
|
286
|
+
onShutdownStart,
|
|
287
|
+
onShutdownComplete,
|
|
288
|
+
onShutdownError
|
|
289
|
+
} = options;
|
|
290
|
+
let shuttingDown = false;
|
|
291
|
+
async function stopTarget() {
|
|
292
|
+
if (Array.isArray(target)) {
|
|
293
|
+
await Promise.all(target.map((w) => w.stop()));
|
|
294
|
+
} else if ("stopAll" in target) {
|
|
295
|
+
await target.stopAll();
|
|
296
|
+
} else {
|
|
297
|
+
await target.stop();
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
const handler = async (signal) => {
|
|
301
|
+
if (shuttingDown) {
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
shuttingDown = true;
|
|
305
|
+
onShutdownStart?.(signal);
|
|
306
|
+
let timer = null;
|
|
307
|
+
if (timeoutMs > 0) {
|
|
308
|
+
timer = setTimeout(() => {
|
|
309
|
+
const err = new Error(`Graceful shutdown timed out after ${timeoutMs}ms`);
|
|
310
|
+
onShutdownError?.(err);
|
|
311
|
+
if (exitOnComplete) {
|
|
312
|
+
process.exit(timeoutExitCode);
|
|
313
|
+
}
|
|
314
|
+
}, timeoutMs);
|
|
315
|
+
timer.unref();
|
|
316
|
+
}
|
|
317
|
+
try {
|
|
318
|
+
await stopTarget();
|
|
319
|
+
if (timer) {
|
|
320
|
+
clearTimeout(timer);
|
|
321
|
+
}
|
|
322
|
+
onShutdownComplete?.();
|
|
323
|
+
if (exitOnComplete) {
|
|
324
|
+
process.exit(exitCode);
|
|
325
|
+
}
|
|
326
|
+
} catch (err) {
|
|
327
|
+
if (timer) {
|
|
328
|
+
clearTimeout(timer);
|
|
329
|
+
}
|
|
330
|
+
onShutdownError?.(err);
|
|
331
|
+
if (exitOnComplete) {
|
|
332
|
+
process.exit(timeoutExitCode);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
};
|
|
336
|
+
for (const sig of signals) {
|
|
337
|
+
process.on(sig, handler);
|
|
338
|
+
}
|
|
339
|
+
return function detach() {
|
|
340
|
+
for (const sig of signals) {
|
|
341
|
+
process.removeListener(sig, handler);
|
|
342
|
+
}
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// src/worker.ts
|
|
219
347
|
var DEFAULT_WORKER_TIMEOUT_MS = 6e4;
|
|
220
348
|
function parseClaimedRows(result) {
|
|
221
349
|
const rows = result.rows;
|
|
@@ -253,7 +381,8 @@ function createWorker(options) {
|
|
|
253
381
|
autoRestore = true,
|
|
254
382
|
pauseCheckIntervalMs = 300,
|
|
255
383
|
requeueExpiredIntervalMs = 0,
|
|
256
|
-
onPumpError
|
|
384
|
+
onPumpError,
|
|
385
|
+
completionBatchSize = 50
|
|
257
386
|
} = options;
|
|
258
387
|
if (!db) {
|
|
259
388
|
throw new Error("Database instance is required");
|
|
@@ -266,6 +395,8 @@ function createWorker(options) {
|
|
|
266
395
|
let lastPauseCheckAt = 0;
|
|
267
396
|
let cachedDbPaused = false;
|
|
268
397
|
let lastRequeueAt = 0;
|
|
398
|
+
let pendingDone = [];
|
|
399
|
+
let flushTimeout = null;
|
|
269
400
|
function notifyPumpError(error) {
|
|
270
401
|
console.error("[workmatic] Pump error:", error);
|
|
271
402
|
onPumpError?.(error);
|
|
@@ -277,13 +408,16 @@ function createWorker(options) {
|
|
|
277
408
|
if (!persistState) return;
|
|
278
409
|
const timestamp = now();
|
|
279
410
|
const key = getStateKey();
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
411
|
+
try {
|
|
412
|
+
await sql`
|
|
413
|
+
INSERT INTO workmatic_settings (queue, paused, updated_at)
|
|
414
|
+
VALUES (${key}, ${state === "paused" ? 1 : state === "running" ? 2 : 0}, ${timestamp})
|
|
415
|
+
ON CONFLICT(queue) DO UPDATE SET
|
|
416
|
+
paused = ${state === "paused" ? 1 : state === "running" ? 2 : 0},
|
|
417
|
+
updated_at = ${timestamp}
|
|
418
|
+
`.execute(db);
|
|
419
|
+
} catch {
|
|
420
|
+
}
|
|
287
421
|
}
|
|
288
422
|
async function loadState() {
|
|
289
423
|
if (!persistState) return null;
|
|
@@ -310,52 +444,104 @@ function createWorker(options) {
|
|
|
310
444
|
async function claimBatch(limit) {
|
|
311
445
|
const timestamp = now();
|
|
312
446
|
const leaseUntil = timestamp + leaseMs;
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
447
|
+
const result = await sql`
|
|
448
|
+
UPDATE workmatic_jobs
|
|
449
|
+
SET status = 'running', lease_until = ${leaseUntil}, updated_at = ${timestamp}
|
|
450
|
+
WHERE rowid IN (
|
|
451
|
+
SELECT rowid FROM workmatic_jobs
|
|
452
|
+
WHERE queue = ${queue}
|
|
453
|
+
AND status = 'ready'
|
|
454
|
+
AND run_at <= ${timestamp}
|
|
455
|
+
ORDER BY priority ASC, id ASC
|
|
456
|
+
LIMIT ${limit}
|
|
457
|
+
)
|
|
458
|
+
RETURNING id, public_id, queue, payload, attempts, max_attempts, priority, created_at, last_error
|
|
459
|
+
`.execute(db);
|
|
460
|
+
return parseClaimedRows(result);
|
|
461
|
+
}
|
|
462
|
+
async function flushDoneBatch() {
|
|
463
|
+
if (flushTimeout !== null) {
|
|
464
|
+
clearImmediate(flushTimeout);
|
|
465
|
+
flushTimeout = null;
|
|
466
|
+
}
|
|
467
|
+
if (pendingDone.length === 0) {
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
const current = pendingDone;
|
|
471
|
+
pendingDone = [];
|
|
472
|
+
const timestamp = now();
|
|
473
|
+
try {
|
|
474
|
+
if (current.length === 1) {
|
|
475
|
+
await db.executeQuery(
|
|
476
|
+
CompiledQuery2.raw(
|
|
477
|
+
"UPDATE workmatic_jobs SET status = 'done', lease_until = 0, updated_at = ? WHERE id = ?",
|
|
478
|
+
[timestamp, current[0].id]
|
|
479
|
+
)
|
|
480
|
+
);
|
|
481
|
+
} else {
|
|
482
|
+
const CHUNK_SIZE = 500;
|
|
483
|
+
for (let i = 0; i < current.length; i += CHUNK_SIZE) {
|
|
484
|
+
const chunk = current.slice(i, i + CHUNK_SIZE);
|
|
485
|
+
const placeholders = chunk.map(() => "?").join(", ");
|
|
486
|
+
const params = [timestamp, ...chunk.map((item) => item.id)];
|
|
487
|
+
await db.executeQuery(
|
|
488
|
+
CompiledQuery2.raw(
|
|
489
|
+
`UPDATE workmatic_jobs SET status = 'done', lease_until = 0, updated_at = ? WHERE id IN (${placeholders})`,
|
|
490
|
+
params
|
|
491
|
+
)
|
|
492
|
+
);
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
for (const item of current) {
|
|
496
|
+
item.resolve();
|
|
497
|
+
}
|
|
498
|
+
} catch (err) {
|
|
499
|
+
for (const item of current) {
|
|
500
|
+
item.reject(err);
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
function markDone(jobId) {
|
|
505
|
+
if (completionBatchSize <= 0) {
|
|
506
|
+
return db.executeQuery(
|
|
507
|
+
CompiledQuery2.raw(
|
|
508
|
+
"UPDATE workmatic_jobs SET status = 'done', lease_until = 0, updated_at = ? WHERE id = ?",
|
|
509
|
+
[now(), jobId]
|
|
324
510
|
)
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
511
|
+
).then(() => {
|
|
512
|
+
});
|
|
513
|
+
}
|
|
514
|
+
return new Promise((resolve, reject) => {
|
|
515
|
+
pendingDone.push({ id: jobId, resolve, reject });
|
|
516
|
+
if (pendingDone.length >= completionBatchSize) {
|
|
517
|
+
void flushDoneBatch();
|
|
518
|
+
} else if (!flushTimeout) {
|
|
519
|
+
flushTimeout = setImmediate(() => {
|
|
520
|
+
flushTimeout = null;
|
|
521
|
+
void flushDoneBatch();
|
|
522
|
+
});
|
|
523
|
+
}
|
|
328
524
|
});
|
|
329
525
|
}
|
|
330
|
-
async function markDone(jobId) {
|
|
331
|
-
await db.updateTable("workmatic_jobs").set({
|
|
332
|
-
status: "done",
|
|
333
|
-
lease_until: 0,
|
|
334
|
-
updated_at: now()
|
|
335
|
-
}).where("id", "=", jobId).execute();
|
|
336
|
-
}
|
|
337
526
|
async function markFailed(jobId, attempts, maxAttempts, error) {
|
|
338
527
|
const timestamp = now();
|
|
339
528
|
const newAttempts = attempts + 1;
|
|
340
529
|
const errorMessage = error.message || String(error);
|
|
341
530
|
if (newAttempts < maxAttempts) {
|
|
342
531
|
const runAt = timestamp + backoff(newAttempts);
|
|
343
|
-
await db.
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
updated_at: timestamp
|
|
350
|
-
}).where("id", "=", jobId).execute();
|
|
532
|
+
await db.executeQuery(
|
|
533
|
+
CompiledQuery2.raw(
|
|
534
|
+
"UPDATE workmatic_jobs SET status = 'ready', attempts = ?, run_at = ?, lease_until = 0, last_error = ?, updated_at = ? WHERE id = ?",
|
|
535
|
+
[newAttempts, runAt, errorMessage, timestamp, jobId]
|
|
536
|
+
)
|
|
537
|
+
);
|
|
351
538
|
} else {
|
|
352
|
-
await db.
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
}).where("id", "=", jobId).execute();
|
|
539
|
+
await db.executeQuery(
|
|
540
|
+
CompiledQuery2.raw(
|
|
541
|
+
"UPDATE workmatic_jobs SET status = 'dead', attempts = ?, lease_until = 0, last_error = ?, updated_at = ? WHERE id = ?",
|
|
542
|
+
[newAttempts, errorMessage, timestamp, jobId]
|
|
543
|
+
)
|
|
544
|
+
);
|
|
359
545
|
}
|
|
360
546
|
}
|
|
361
547
|
async function withTimeout(promise, ms, jobId) {
|
|
@@ -402,8 +588,13 @@ function createWorker(options) {
|
|
|
402
588
|
}
|
|
403
589
|
}
|
|
404
590
|
async function isQueuePausedInDb() {
|
|
405
|
-
const
|
|
406
|
-
|
|
591
|
+
const result = await db.executeQuery(
|
|
592
|
+
CompiledQuery2.raw(
|
|
593
|
+
"SELECT paused FROM workmatic_settings WHERE queue = ? LIMIT 1",
|
|
594
|
+
[queue]
|
|
595
|
+
)
|
|
596
|
+
);
|
|
597
|
+
return result.rows[0]?.paused === 1;
|
|
407
598
|
}
|
|
408
599
|
async function pump() {
|
|
409
600
|
if (!running) {
|
|
@@ -458,8 +649,7 @@ function createWorker(options) {
|
|
|
458
649
|
running = true;
|
|
459
650
|
paused = false;
|
|
460
651
|
fastqQueue = fastq.promise(processJob, concurrency);
|
|
461
|
-
saveState("running")
|
|
462
|
-
});
|
|
652
|
+
void saveState("running");
|
|
463
653
|
pump();
|
|
464
654
|
},
|
|
465
655
|
async stop() {
|
|
@@ -471,27 +661,28 @@ function createWorker(options) {
|
|
|
471
661
|
clearTimeout(pumpTimeout);
|
|
472
662
|
pumpTimeout = null;
|
|
473
663
|
}
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
}
|
|
664
|
+
await fastqQueue.drained();
|
|
665
|
+
fastqQueue = null;
|
|
666
|
+
await flushDoneBatch();
|
|
478
667
|
await saveState("stopped");
|
|
479
668
|
},
|
|
480
669
|
pause() {
|
|
481
670
|
paused = true;
|
|
482
|
-
|
|
483
|
-
|
|
671
|
+
void flushDoneBatch();
|
|
672
|
+
void saveState("paused");
|
|
484
673
|
},
|
|
485
674
|
resume() {
|
|
486
675
|
paused = false;
|
|
487
|
-
saveState("running")
|
|
488
|
-
});
|
|
676
|
+
void saveState("running");
|
|
489
677
|
},
|
|
490
678
|
async stats() {
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
679
|
+
await flushDoneBatch();
|
|
680
|
+
const result = await db.executeQuery(
|
|
681
|
+
CompiledQuery2.raw(
|
|
682
|
+
"SELECT status, count(*) AS count FROM workmatic_jobs WHERE queue = ? GROUP BY status",
|
|
683
|
+
[queue]
|
|
684
|
+
)
|
|
685
|
+
);
|
|
495
686
|
const stats = {
|
|
496
687
|
ready: 0,
|
|
497
688
|
running: 0,
|
|
@@ -499,7 +690,7 @@ function createWorker(options) {
|
|
|
499
690
|
dead: 0,
|
|
500
691
|
total: 0
|
|
501
692
|
};
|
|
502
|
-
for (const row of result) {
|
|
693
|
+
for (const row of result.rows) {
|
|
503
694
|
const status = row.status;
|
|
504
695
|
const count = Number(row.count);
|
|
505
696
|
if (status in stats) {
|
|
@@ -535,6 +726,22 @@ function createWorker(options) {
|
|
|
535
726
|
}
|
|
536
727
|
const result = await query.execute();
|
|
537
728
|
return Number(result[0]?.numDeletedRows ?? 0);
|
|
729
|
+
},
|
|
730
|
+
wakeUp() {
|
|
731
|
+
if (!running || paused) {
|
|
732
|
+
return;
|
|
733
|
+
}
|
|
734
|
+
if (pumpTimeout) {
|
|
735
|
+
clearTimeout(pumpTimeout);
|
|
736
|
+
pumpTimeout = null;
|
|
737
|
+
}
|
|
738
|
+
pumpTimeout = setTimeout(pump, 0);
|
|
739
|
+
},
|
|
740
|
+
async flushCompletions() {
|
|
741
|
+
await flushDoneBatch();
|
|
742
|
+
},
|
|
743
|
+
attachSignalHandlers(options2) {
|
|
744
|
+
return attachGracefulShutdown(this, options2);
|
|
538
745
|
}
|
|
539
746
|
};
|
|
540
747
|
if (persistState && autoRestore) {
|
|
@@ -567,7 +774,15 @@ function createOrchestrator(options) {
|
|
|
567
774
|
function ensureEntry(queue) {
|
|
568
775
|
let entry = registry.get(queue);
|
|
569
776
|
if (!entry) {
|
|
570
|
-
entry = {
|
|
777
|
+
entry = {
|
|
778
|
+
client: createClient({
|
|
779
|
+
db,
|
|
780
|
+
queue,
|
|
781
|
+
onJobAdded: () => {
|
|
782
|
+
registry.get(queue)?.worker?.wakeUp();
|
|
783
|
+
}
|
|
784
|
+
})
|
|
785
|
+
};
|
|
571
786
|
registry.set(queue, entry);
|
|
572
787
|
}
|
|
573
788
|
return entry;
|
|
@@ -575,7 +790,7 @@ function createOrchestrator(options) {
|
|
|
575
790
|
async function setQueuePaused(queue, paused) {
|
|
576
791
|
const timestamp = now();
|
|
577
792
|
const value = paused ? 1 : 0;
|
|
578
|
-
await
|
|
793
|
+
await sql2`
|
|
579
794
|
INSERT INTO workmatic_settings (queue, paused, updated_at)
|
|
580
795
|
VALUES (${queue}, ${value}, ${timestamp})
|
|
581
796
|
ON CONFLICT(queue) DO UPDATE SET
|
|
@@ -740,11 +955,750 @@ function createOrchestrator(options) {
|
|
|
740
955
|
return;
|
|
741
956
|
}
|
|
742
957
|
await db.updateTable("workmatic_jobs").set({ queue: toQueue, updated_at: timestamp }).where("public_id", "=", publicId).execute();
|
|
958
|
+
},
|
|
959
|
+
attachSignalHandlers(options2) {
|
|
960
|
+
return attachGracefulShutdown(this, options2);
|
|
743
961
|
}
|
|
744
962
|
};
|
|
745
963
|
return orchestrator;
|
|
746
964
|
}
|
|
747
965
|
|
|
966
|
+
// src/mcp/server.ts
|
|
967
|
+
import readline from "readline";
|
|
968
|
+
import { EventEmitter } from "events";
|
|
969
|
+
|
|
970
|
+
// src/mcp/tools.ts
|
|
971
|
+
import { sql as sql3 } from "kysely";
|
|
972
|
+
var MCP_TOOL_DEFINITIONS = [
|
|
973
|
+
{
|
|
974
|
+
name: "workmatic_list_queues",
|
|
975
|
+
description: "List all queues present in the Workmatic database along with job counts",
|
|
976
|
+
inputSchema: {
|
|
977
|
+
type: "object",
|
|
978
|
+
properties: {}
|
|
979
|
+
}
|
|
980
|
+
},
|
|
981
|
+
{
|
|
982
|
+
name: "workmatic_get_stats",
|
|
983
|
+
description: "Get real-time job counts (ready, running, done, dead, total) for a specific queue or all queues",
|
|
984
|
+
inputSchema: {
|
|
985
|
+
type: "object",
|
|
986
|
+
properties: {
|
|
987
|
+
queue: {
|
|
988
|
+
type: "string",
|
|
989
|
+
description: "Optional queue name. If omitted, stats for all queues will be returned."
|
|
990
|
+
}
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
},
|
|
994
|
+
{
|
|
995
|
+
name: "workmatic_list_jobs",
|
|
996
|
+
description: "List jobs in the database filtered by queue, status, and limit",
|
|
997
|
+
inputSchema: {
|
|
998
|
+
type: "object",
|
|
999
|
+
properties: {
|
|
1000
|
+
queue: {
|
|
1001
|
+
type: "string",
|
|
1002
|
+
description: "Filter by queue name"
|
|
1003
|
+
},
|
|
1004
|
+
status: {
|
|
1005
|
+
type: "string",
|
|
1006
|
+
enum: ["ready", "running", "done", "dead"],
|
|
1007
|
+
description: "Filter by job status"
|
|
1008
|
+
},
|
|
1009
|
+
limit: {
|
|
1010
|
+
type: "number",
|
|
1011
|
+
description: "Maximum number of jobs to return (default: 20, max: 100)"
|
|
1012
|
+
},
|
|
1013
|
+
offset: {
|
|
1014
|
+
type: "number",
|
|
1015
|
+
description: "Number of jobs to skip for pagination (default: 0)"
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
},
|
|
1020
|
+
{
|
|
1021
|
+
name: "workmatic_get_dead_jobs",
|
|
1022
|
+
description: "Retrieve failed/dead jobs with error details and payloads for debugging",
|
|
1023
|
+
inputSchema: {
|
|
1024
|
+
type: "object",
|
|
1025
|
+
properties: {
|
|
1026
|
+
queue: {
|
|
1027
|
+
type: "string",
|
|
1028
|
+
description: "Filter dead jobs by queue name"
|
|
1029
|
+
},
|
|
1030
|
+
limit: {
|
|
1031
|
+
type: "number",
|
|
1032
|
+
description: "Maximum number of dead jobs to return (default: 20)"
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
},
|
|
1037
|
+
{
|
|
1038
|
+
name: "workmatic_add_job",
|
|
1039
|
+
description: "Enqueue a new background job into Workmatic",
|
|
1040
|
+
inputSchema: {
|
|
1041
|
+
type: "object",
|
|
1042
|
+
properties: {
|
|
1043
|
+
queue: {
|
|
1044
|
+
type: "string",
|
|
1045
|
+
description: 'Target queue name (default: "default")'
|
|
1046
|
+
},
|
|
1047
|
+
payload: {
|
|
1048
|
+
description: "Job payload (JSON object, string, number, etc.)"
|
|
1049
|
+
},
|
|
1050
|
+
priority: {
|
|
1051
|
+
type: "number",
|
|
1052
|
+
description: "Job priority (lower number = higher priority, default: 0)"
|
|
1053
|
+
},
|
|
1054
|
+
delayMs: {
|
|
1055
|
+
type: "number",
|
|
1056
|
+
description: "Delay in milliseconds before job can run (default: 0)"
|
|
1057
|
+
},
|
|
1058
|
+
maxAttempts: {
|
|
1059
|
+
type: "number",
|
|
1060
|
+
description: "Maximum execution retry attempts (default: 3)"
|
|
1061
|
+
}
|
|
1062
|
+
},
|
|
1063
|
+
required: ["payload"]
|
|
1064
|
+
}
|
|
1065
|
+
},
|
|
1066
|
+
{
|
|
1067
|
+
name: "workmatic_retry_job",
|
|
1068
|
+
description: "Retry a specific dead or failed job by resetting it to ready status",
|
|
1069
|
+
inputSchema: {
|
|
1070
|
+
type: "object",
|
|
1071
|
+
properties: {
|
|
1072
|
+
publicId: {
|
|
1073
|
+
type: "string",
|
|
1074
|
+
description: "Public ID of the job to retry"
|
|
1075
|
+
}
|
|
1076
|
+
},
|
|
1077
|
+
required: ["publicId"]
|
|
1078
|
+
}
|
|
1079
|
+
},
|
|
1080
|
+
{
|
|
1081
|
+
name: "workmatic_retry_all_dead",
|
|
1082
|
+
description: "Retry all dead jobs (optionally in a specific queue) by resetting them to ready status",
|
|
1083
|
+
inputSchema: {
|
|
1084
|
+
type: "object",
|
|
1085
|
+
properties: {
|
|
1086
|
+
queue: {
|
|
1087
|
+
type: "string",
|
|
1088
|
+
description: "Optional queue name to restrict retrying"
|
|
1089
|
+
}
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
},
|
|
1093
|
+
{
|
|
1094
|
+
name: "workmatic_pause_queue",
|
|
1095
|
+
description: "Pause a queue so workers stop claiming new jobs from it",
|
|
1096
|
+
inputSchema: {
|
|
1097
|
+
type: "object",
|
|
1098
|
+
properties: {
|
|
1099
|
+
queue: {
|
|
1100
|
+
type: "string",
|
|
1101
|
+
description: "Queue name to pause"
|
|
1102
|
+
}
|
|
1103
|
+
},
|
|
1104
|
+
required: ["queue"]
|
|
1105
|
+
}
|
|
1106
|
+
},
|
|
1107
|
+
{
|
|
1108
|
+
name: "workmatic_resume_queue",
|
|
1109
|
+
description: "Resume a paused queue so workers resume claiming jobs",
|
|
1110
|
+
inputSchema: {
|
|
1111
|
+
type: "object",
|
|
1112
|
+
properties: {
|
|
1113
|
+
queue: {
|
|
1114
|
+
type: "string",
|
|
1115
|
+
description: "Queue name to resume"
|
|
1116
|
+
}
|
|
1117
|
+
},
|
|
1118
|
+
required: ["queue"]
|
|
1119
|
+
}
|
|
1120
|
+
},
|
|
1121
|
+
{
|
|
1122
|
+
name: "workmatic_purge_jobs",
|
|
1123
|
+
description: "Permanently remove done or dead jobs from the database",
|
|
1124
|
+
inputSchema: {
|
|
1125
|
+
type: "object",
|
|
1126
|
+
properties: {
|
|
1127
|
+
queue: {
|
|
1128
|
+
type: "string",
|
|
1129
|
+
description: "Queue name to purge jobs from (optional)"
|
|
1130
|
+
},
|
|
1131
|
+
status: {
|
|
1132
|
+
type: "string",
|
|
1133
|
+
enum: ["done", "dead", "all"],
|
|
1134
|
+
description: 'Status of jobs to purge (default: "done")'
|
|
1135
|
+
}
|
|
1136
|
+
}
|
|
1137
|
+
}
|
|
1138
|
+
},
|
|
1139
|
+
{
|
|
1140
|
+
name: "workmatic_transfer_jobs",
|
|
1141
|
+
description: "Move jobs from one queue to another (e.g. from dead-letter queue back to primary)",
|
|
1142
|
+
inputSchema: {
|
|
1143
|
+
type: "object",
|
|
1144
|
+
properties: {
|
|
1145
|
+
fromQueue: {
|
|
1146
|
+
type: "string",
|
|
1147
|
+
description: "Source queue name"
|
|
1148
|
+
},
|
|
1149
|
+
toQueue: {
|
|
1150
|
+
type: "string",
|
|
1151
|
+
description: "Destination queue name"
|
|
1152
|
+
},
|
|
1153
|
+
status: {
|
|
1154
|
+
type: "string",
|
|
1155
|
+
enum: ["ready", "dead"],
|
|
1156
|
+
description: 'Status of jobs to transfer (default: "ready")'
|
|
1157
|
+
},
|
|
1158
|
+
limit: {
|
|
1159
|
+
type: "number",
|
|
1160
|
+
description: "Maximum number of jobs to transfer (default: 1000)"
|
|
1161
|
+
},
|
|
1162
|
+
resetForRetry: {
|
|
1163
|
+
type: "boolean",
|
|
1164
|
+
description: "If transferring dead jobs, reset their status to ready (default: false)"
|
|
1165
|
+
}
|
|
1166
|
+
},
|
|
1167
|
+
required: ["fromQueue", "toQueue"]
|
|
1168
|
+
}
|
|
1169
|
+
},
|
|
1170
|
+
{
|
|
1171
|
+
name: "workmatic_update_job_status",
|
|
1172
|
+
description: "Update the status of a specific job (ready, done, dead) and signal workers / listeners",
|
|
1173
|
+
inputSchema: {
|
|
1174
|
+
type: "object",
|
|
1175
|
+
properties: {
|
|
1176
|
+
publicId: {
|
|
1177
|
+
type: "string",
|
|
1178
|
+
description: "Public ID of the job to update"
|
|
1179
|
+
},
|
|
1180
|
+
status: {
|
|
1181
|
+
type: "string",
|
|
1182
|
+
enum: ["ready", "done", "dead"],
|
|
1183
|
+
description: "New status for the job"
|
|
1184
|
+
},
|
|
1185
|
+
error: {
|
|
1186
|
+
type: "string",
|
|
1187
|
+
description: "Optional error message when marking as dead or recording failure details"
|
|
1188
|
+
},
|
|
1189
|
+
resetAttempts: {
|
|
1190
|
+
type: "boolean",
|
|
1191
|
+
description: "Whether to reset attempts to 0 (default: true if status is ready, false otherwise)"
|
|
1192
|
+
},
|
|
1193
|
+
delayMs: {
|
|
1194
|
+
type: "number",
|
|
1195
|
+
description: "Delay in milliseconds before the job becomes ready (default: 0)"
|
|
1196
|
+
}
|
|
1197
|
+
},
|
|
1198
|
+
required: ["publicId", "status"]
|
|
1199
|
+
}
|
|
1200
|
+
}
|
|
1201
|
+
];
|
|
1202
|
+
async function executeTool(db, name, args2 = {}, context) {
|
|
1203
|
+
switch (name) {
|
|
1204
|
+
case "workmatic_list_queues": {
|
|
1205
|
+
const qJobs = await db.selectFrom("workmatic_jobs").select("queue").distinct().execute();
|
|
1206
|
+
const qSettings = await db.selectFrom("workmatic_settings").select("queue").distinct().execute();
|
|
1207
|
+
const set = /* @__PURE__ */ new Set();
|
|
1208
|
+
for (const r of qJobs) set.add(r.queue);
|
|
1209
|
+
for (const r of qSettings) {
|
|
1210
|
+
if (!r.queue.startsWith("worker_state_")) {
|
|
1211
|
+
set.add(r.queue);
|
|
1212
|
+
}
|
|
1213
|
+
}
|
|
1214
|
+
const queueList = Array.from(set).sort();
|
|
1215
|
+
const result = [];
|
|
1216
|
+
for (const q of queueList) {
|
|
1217
|
+
const client = createClient({ db, queue: q });
|
|
1218
|
+
const stats = await client.stats();
|
|
1219
|
+
const setting = await db.selectFrom("workmatic_settings").select("paused").where("queue", "=", q).executeTakeFirst();
|
|
1220
|
+
result.push({
|
|
1221
|
+
queue: q,
|
|
1222
|
+
stats: {
|
|
1223
|
+
ready: stats.ready,
|
|
1224
|
+
running: stats.running,
|
|
1225
|
+
done: stats.done,
|
|
1226
|
+
dead: stats.dead,
|
|
1227
|
+
total: stats.total
|
|
1228
|
+
},
|
|
1229
|
+
isPaused: setting?.paused === 1
|
|
1230
|
+
});
|
|
1231
|
+
}
|
|
1232
|
+
return { queues: result, totalQueues: result.length };
|
|
1233
|
+
}
|
|
1234
|
+
case "workmatic_get_stats": {
|
|
1235
|
+
const queue = args2.queue;
|
|
1236
|
+
if (queue) {
|
|
1237
|
+
const client = createClient({ db, queue });
|
|
1238
|
+
return { queue, stats: await client.stats() };
|
|
1239
|
+
}
|
|
1240
|
+
const listRes = await executeTool(db, "workmatic_list_queues");
|
|
1241
|
+
const summary = {};
|
|
1242
|
+
const grandTotal = { ready: 0, running: 0, done: 0, dead: 0, total: 0 };
|
|
1243
|
+
for (const item of listRes.queues) {
|
|
1244
|
+
summary[item.queue] = item.stats;
|
|
1245
|
+
grandTotal.ready += item.stats.ready;
|
|
1246
|
+
grandTotal.running += item.stats.running;
|
|
1247
|
+
grandTotal.done += item.stats.done;
|
|
1248
|
+
grandTotal.dead += item.stats.dead;
|
|
1249
|
+
grandTotal.total += item.stats.total;
|
|
1250
|
+
}
|
|
1251
|
+
return { queues: summary, grandTotal };
|
|
1252
|
+
}
|
|
1253
|
+
case "workmatic_list_jobs": {
|
|
1254
|
+
const queue = args2.queue;
|
|
1255
|
+
const status = args2.status;
|
|
1256
|
+
const limit = Math.min(Math.max(Number(args2.limit ?? 20), 1), 100);
|
|
1257
|
+
const offset = Math.max(Number(args2.offset ?? 0), 0);
|
|
1258
|
+
let query = db.selectFrom("workmatic_jobs").select([
|
|
1259
|
+
"id",
|
|
1260
|
+
"public_id",
|
|
1261
|
+
"queue",
|
|
1262
|
+
"status",
|
|
1263
|
+
"priority",
|
|
1264
|
+
"payload",
|
|
1265
|
+
"attempts",
|
|
1266
|
+
"max_attempts",
|
|
1267
|
+
"run_at",
|
|
1268
|
+
"created_at",
|
|
1269
|
+
"updated_at",
|
|
1270
|
+
"last_error"
|
|
1271
|
+
]);
|
|
1272
|
+
if (queue) {
|
|
1273
|
+
query = query.where("queue", "=", queue);
|
|
1274
|
+
}
|
|
1275
|
+
if (status) {
|
|
1276
|
+
query = query.where("status", "=", status);
|
|
1277
|
+
}
|
|
1278
|
+
const rows = await query.orderBy("priority", "asc").orderBy("id", "asc").limit(limit).offset(offset).execute();
|
|
1279
|
+
const jobs = rows.map((r) => {
|
|
1280
|
+
let parsedPayload;
|
|
1281
|
+
try {
|
|
1282
|
+
parsedPayload = JSON.parse(r.payload);
|
|
1283
|
+
} catch {
|
|
1284
|
+
parsedPayload = r.payload;
|
|
1285
|
+
}
|
|
1286
|
+
return {
|
|
1287
|
+
id: r.id,
|
|
1288
|
+
publicId: r.public_id,
|
|
1289
|
+
queue: r.queue,
|
|
1290
|
+
status: r.status,
|
|
1291
|
+
priority: r.priority,
|
|
1292
|
+
attempts: r.attempts,
|
|
1293
|
+
maxAttempts: r.max_attempts,
|
|
1294
|
+
runAt: r.run_at,
|
|
1295
|
+
createdAt: r.created_at,
|
|
1296
|
+
updatedAt: r.updated_at,
|
|
1297
|
+
lastError: r.last_error,
|
|
1298
|
+
payload: parsedPayload
|
|
1299
|
+
};
|
|
1300
|
+
});
|
|
1301
|
+
return { jobs, count: jobs.length, limit, offset };
|
|
1302
|
+
}
|
|
1303
|
+
case "workmatic_get_dead_jobs": {
|
|
1304
|
+
const queue = args2.queue;
|
|
1305
|
+
const limit = Math.min(Math.max(Number(args2.limit ?? 20), 1), 100);
|
|
1306
|
+
return executeTool(db, "workmatic_list_jobs", {
|
|
1307
|
+
queue,
|
|
1308
|
+
status: "dead",
|
|
1309
|
+
limit
|
|
1310
|
+
});
|
|
1311
|
+
}
|
|
1312
|
+
case "workmatic_add_job": {
|
|
1313
|
+
const queue = args2.queue || "default";
|
|
1314
|
+
const payload = args2.payload;
|
|
1315
|
+
const priority = args2.priority !== void 0 ? Number(args2.priority) : 0;
|
|
1316
|
+
const delayMs = args2.delayMs !== void 0 ? Number(args2.delayMs) : 0;
|
|
1317
|
+
const maxAttempts = args2.maxAttempts !== void 0 ? Number(args2.maxAttempts) : 3;
|
|
1318
|
+
const client = createClient({ db, queue });
|
|
1319
|
+
const result = await client.add(payload, { priority, delayMs, maxAttempts });
|
|
1320
|
+
return { ok: true, id: result.id, queue };
|
|
1321
|
+
}
|
|
1322
|
+
case "workmatic_retry_job": {
|
|
1323
|
+
const publicId = args2.publicId;
|
|
1324
|
+
if (!publicId) {
|
|
1325
|
+
throw new Error("publicId is required");
|
|
1326
|
+
}
|
|
1327
|
+
const job = await db.selectFrom("workmatic_jobs").select(["queue", "status"]).where("public_id", "=", publicId).executeTakeFirst();
|
|
1328
|
+
if (!job) {
|
|
1329
|
+
throw new Error(`Job not found: ${publicId}`);
|
|
1330
|
+
}
|
|
1331
|
+
const timestamp = now();
|
|
1332
|
+
await db.updateTable("workmatic_jobs").set({
|
|
1333
|
+
status: "ready",
|
|
1334
|
+
attempts: 0,
|
|
1335
|
+
lease_until: 0,
|
|
1336
|
+
last_error: null,
|
|
1337
|
+
run_at: timestamp,
|
|
1338
|
+
updated_at: timestamp
|
|
1339
|
+
}).where("public_id", "=", publicId).execute();
|
|
1340
|
+
context?.onJobStatusChanged?.({
|
|
1341
|
+
publicId,
|
|
1342
|
+
queue: job.queue,
|
|
1343
|
+
previousStatus: job.status,
|
|
1344
|
+
status: "ready",
|
|
1345
|
+
timestamp,
|
|
1346
|
+
error: null
|
|
1347
|
+
});
|
|
1348
|
+
return { ok: true, id: publicId, message: `Job ${publicId} reset to ready` };
|
|
1349
|
+
}
|
|
1350
|
+
case "workmatic_retry_all_dead": {
|
|
1351
|
+
const queue = args2.queue;
|
|
1352
|
+
const timestamp = now();
|
|
1353
|
+
let query = db.updateTable("workmatic_jobs").set({
|
|
1354
|
+
status: "ready",
|
|
1355
|
+
attempts: 0,
|
|
1356
|
+
lease_until: 0,
|
|
1357
|
+
last_error: null,
|
|
1358
|
+
run_at: timestamp,
|
|
1359
|
+
updated_at: timestamp
|
|
1360
|
+
}).where("status", "=", "dead");
|
|
1361
|
+
if (queue) {
|
|
1362
|
+
query = query.where("queue", "=", queue);
|
|
1363
|
+
}
|
|
1364
|
+
const res = await query.execute();
|
|
1365
|
+
const retriedCount = Number(res[0].numUpdatedRows);
|
|
1366
|
+
if (retriedCount > 0) {
|
|
1367
|
+
context?.onJobStatusChanged?.({
|
|
1368
|
+
publicId: "*",
|
|
1369
|
+
queue: queue ?? "*",
|
|
1370
|
+
previousStatus: "dead",
|
|
1371
|
+
status: "ready",
|
|
1372
|
+
timestamp,
|
|
1373
|
+
error: null
|
|
1374
|
+
});
|
|
1375
|
+
}
|
|
1376
|
+
return { ok: true, retriedCount, queue: queue ?? "all" };
|
|
1377
|
+
}
|
|
1378
|
+
case "workmatic_update_job_status": {
|
|
1379
|
+
const publicId = args2.publicId;
|
|
1380
|
+
const status = args2.status;
|
|
1381
|
+
if (!publicId) {
|
|
1382
|
+
throw new Error("publicId is required");
|
|
1383
|
+
}
|
|
1384
|
+
if (!status || !["ready", "done", "dead"].includes(status)) {
|
|
1385
|
+
throw new Error("status is required and must be 'ready', 'done', or 'dead'");
|
|
1386
|
+
}
|
|
1387
|
+
const job = await db.selectFrom("workmatic_jobs").select(["id", "public_id", "queue", "status", "attempts"]).where("public_id", "=", publicId).executeTakeFirst();
|
|
1388
|
+
if (!job) {
|
|
1389
|
+
throw new Error(`Job not found: ${publicId}`);
|
|
1390
|
+
}
|
|
1391
|
+
if (job.status === status) {
|
|
1392
|
+
return {
|
|
1393
|
+
ok: true,
|
|
1394
|
+
id: publicId,
|
|
1395
|
+
queue: job.queue,
|
|
1396
|
+
status,
|
|
1397
|
+
unchanged: true
|
|
1398
|
+
};
|
|
1399
|
+
}
|
|
1400
|
+
const timestamp = now();
|
|
1401
|
+
const previousStatus = job.status;
|
|
1402
|
+
const delayMs = Math.max(Number(args2.delayMs ?? 0), 0);
|
|
1403
|
+
const resetAttempts = args2.resetAttempts !== void 0 ? Boolean(args2.resetAttempts) : status === "ready";
|
|
1404
|
+
const updateData = {
|
|
1405
|
+
status,
|
|
1406
|
+
updated_at: timestamp,
|
|
1407
|
+
lease_until: 0
|
|
1408
|
+
};
|
|
1409
|
+
if (status === "ready") {
|
|
1410
|
+
updateData.run_at = timestamp + delayMs;
|
|
1411
|
+
}
|
|
1412
|
+
if (resetAttempts) {
|
|
1413
|
+
updateData.attempts = 0;
|
|
1414
|
+
}
|
|
1415
|
+
if (args2.error !== void 0) {
|
|
1416
|
+
updateData.last_error = args2.error;
|
|
1417
|
+
} else if (status === "ready") {
|
|
1418
|
+
updateData.last_error = null;
|
|
1419
|
+
}
|
|
1420
|
+
await db.updateTable("workmatic_jobs").set(updateData).where("public_id", "=", publicId).execute();
|
|
1421
|
+
const event = {
|
|
1422
|
+
publicId,
|
|
1423
|
+
queue: job.queue,
|
|
1424
|
+
previousStatus,
|
|
1425
|
+
status,
|
|
1426
|
+
timestamp,
|
|
1427
|
+
error: updateData.last_error ?? null
|
|
1428
|
+
};
|
|
1429
|
+
context?.onJobStatusChanged?.(event);
|
|
1430
|
+
return {
|
|
1431
|
+
ok: true,
|
|
1432
|
+
id: publicId,
|
|
1433
|
+
queue: job.queue,
|
|
1434
|
+
previousStatus,
|
|
1435
|
+
status,
|
|
1436
|
+
signaled: true
|
|
1437
|
+
};
|
|
1438
|
+
}
|
|
1439
|
+
case "workmatic_pause_queue": {
|
|
1440
|
+
const queue = args2.queue;
|
|
1441
|
+
if (!queue) {
|
|
1442
|
+
throw new Error("queue is required");
|
|
1443
|
+
}
|
|
1444
|
+
const timestamp = now();
|
|
1445
|
+
await sql3`
|
|
1446
|
+
INSERT INTO workmatic_settings (queue, paused, updated_at)
|
|
1447
|
+
VALUES (${queue}, 1, ${timestamp})
|
|
1448
|
+
ON CONFLICT(queue) DO UPDATE SET
|
|
1449
|
+
paused = 1,
|
|
1450
|
+
updated_at = ${timestamp}
|
|
1451
|
+
`.execute(db);
|
|
1452
|
+
return { ok: true, queue, paused: true };
|
|
1453
|
+
}
|
|
1454
|
+
case "workmatic_resume_queue": {
|
|
1455
|
+
const queue = args2.queue;
|
|
1456
|
+
if (!queue) {
|
|
1457
|
+
throw new Error("queue is required");
|
|
1458
|
+
}
|
|
1459
|
+
const timestamp = now();
|
|
1460
|
+
await sql3`
|
|
1461
|
+
INSERT INTO workmatic_settings (queue, paused, updated_at)
|
|
1462
|
+
VALUES (${queue}, 0, ${timestamp})
|
|
1463
|
+
ON CONFLICT(queue) DO UPDATE SET
|
|
1464
|
+
paused = 0,
|
|
1465
|
+
updated_at = ${timestamp}
|
|
1466
|
+
`.execute(db);
|
|
1467
|
+
return { ok: true, queue, paused: false };
|
|
1468
|
+
}
|
|
1469
|
+
case "workmatic_purge_jobs": {
|
|
1470
|
+
const queue = args2.queue;
|
|
1471
|
+
const status = args2.status || "done";
|
|
1472
|
+
let query = db.deleteFrom("workmatic_jobs");
|
|
1473
|
+
if (queue) {
|
|
1474
|
+
query = query.where("queue", "=", queue);
|
|
1475
|
+
}
|
|
1476
|
+
if (status !== "all") {
|
|
1477
|
+
query = query.where("status", "=", status);
|
|
1478
|
+
}
|
|
1479
|
+
const res = await query.execute();
|
|
1480
|
+
const deletedCount = Number(res[0].numDeletedRows);
|
|
1481
|
+
return { ok: true, deletedCount, queue: queue ?? "all", status };
|
|
1482
|
+
}
|
|
1483
|
+
case "workmatic_transfer_jobs": {
|
|
1484
|
+
const fromQueue = args2.fromQueue;
|
|
1485
|
+
const toQueue = args2.toQueue;
|
|
1486
|
+
if (!fromQueue || !toQueue) {
|
|
1487
|
+
throw new Error("fromQueue and toQueue are required");
|
|
1488
|
+
}
|
|
1489
|
+
if (fromQueue === toQueue) {
|
|
1490
|
+
return { ok: true, moved: 0 };
|
|
1491
|
+
}
|
|
1492
|
+
const status = args2.status || "ready";
|
|
1493
|
+
const limit = Math.max(Number(args2.limit ?? 1e3), 1);
|
|
1494
|
+
const resetForRetry = Boolean(args2.resetForRetry);
|
|
1495
|
+
const orch = createOrchestrator({ db });
|
|
1496
|
+
const result = await orch.transfer({
|
|
1497
|
+
from: fromQueue,
|
|
1498
|
+
to: toQueue,
|
|
1499
|
+
status,
|
|
1500
|
+
limit,
|
|
1501
|
+
resetForRetry
|
|
1502
|
+
});
|
|
1503
|
+
return {
|
|
1504
|
+
ok: true,
|
|
1505
|
+
moved: result.moved,
|
|
1506
|
+
fromQueue,
|
|
1507
|
+
toQueue,
|
|
1508
|
+
status: resetForRetry && status === "dead" ? "ready" : status
|
|
1509
|
+
};
|
|
1510
|
+
}
|
|
1511
|
+
default:
|
|
1512
|
+
throw new Error(`Unknown tool: ${name}`);
|
|
1513
|
+
}
|
|
1514
|
+
}
|
|
1515
|
+
|
|
1516
|
+
// src/mcp/server.ts
|
|
1517
|
+
function createMcpServer(options) {
|
|
1518
|
+
const db = options.db;
|
|
1519
|
+
const input = options.input ?? process.stdin;
|
|
1520
|
+
const output = options.output ?? process.stdout;
|
|
1521
|
+
const emitter = new EventEmitter();
|
|
1522
|
+
let rl = null;
|
|
1523
|
+
let isRunning = false;
|
|
1524
|
+
function notifyJobStatusChanged(event) {
|
|
1525
|
+
if (options.onJobStatusChanged) {
|
|
1526
|
+
options.onJobStatusChanged(event);
|
|
1527
|
+
}
|
|
1528
|
+
emitter.emit("jobStatusChanged", event);
|
|
1529
|
+
if (event.status === "ready") {
|
|
1530
|
+
if (options.orchestrator) {
|
|
1531
|
+
if (event.queue === "*") {
|
|
1532
|
+
for (const worker of options.orchestrator.workers()) {
|
|
1533
|
+
worker.wakeUp();
|
|
1534
|
+
}
|
|
1535
|
+
} else {
|
|
1536
|
+
try {
|
|
1537
|
+
options.orchestrator.worker(event.queue).wakeUp();
|
|
1538
|
+
} catch {
|
|
1539
|
+
}
|
|
1540
|
+
}
|
|
1541
|
+
}
|
|
1542
|
+
if (options.workers) {
|
|
1543
|
+
for (const worker of options.workers) {
|
|
1544
|
+
if (event.queue === "*" || worker.queue === event.queue) {
|
|
1545
|
+
worker.wakeUp();
|
|
1546
|
+
}
|
|
1547
|
+
}
|
|
1548
|
+
}
|
|
1549
|
+
}
|
|
1550
|
+
if (isRunning) {
|
|
1551
|
+
output.write(
|
|
1552
|
+
JSON.stringify({
|
|
1553
|
+
jsonrpc: "2.0",
|
|
1554
|
+
method: "notifications/workmatic/job_status_changed",
|
|
1555
|
+
params: event
|
|
1556
|
+
}) + "\n"
|
|
1557
|
+
);
|
|
1558
|
+
}
|
|
1559
|
+
}
|
|
1560
|
+
async function handleMessage(raw) {
|
|
1561
|
+
const trimmed = raw.trim();
|
|
1562
|
+
if (!trimmed) {
|
|
1563
|
+
return null;
|
|
1564
|
+
}
|
|
1565
|
+
let msg;
|
|
1566
|
+
try {
|
|
1567
|
+
msg = JSON.parse(trimmed);
|
|
1568
|
+
} catch {
|
|
1569
|
+
return JSON.stringify({
|
|
1570
|
+
jsonrpc: "2.0",
|
|
1571
|
+
id: null,
|
|
1572
|
+
error: {
|
|
1573
|
+
code: -32700,
|
|
1574
|
+
message: "Parse error"
|
|
1575
|
+
}
|
|
1576
|
+
});
|
|
1577
|
+
}
|
|
1578
|
+
if (!msg || typeof msg !== "object" || msg.jsonrpc !== "2.0" || typeof msg.method !== "string") {
|
|
1579
|
+
return JSON.stringify({
|
|
1580
|
+
jsonrpc: "2.0",
|
|
1581
|
+
id: msg?.id ?? null,
|
|
1582
|
+
error: {
|
|
1583
|
+
code: -32600,
|
|
1584
|
+
message: "Invalid Request"
|
|
1585
|
+
}
|
|
1586
|
+
});
|
|
1587
|
+
}
|
|
1588
|
+
const isNotification = msg.id === void 0;
|
|
1589
|
+
switch (msg.method) {
|
|
1590
|
+
case "initialize": {
|
|
1591
|
+
const result = {
|
|
1592
|
+
protocolVersion: "2024-11-05",
|
|
1593
|
+
capabilities: {
|
|
1594
|
+
tools: {}
|
|
1595
|
+
},
|
|
1596
|
+
serverInfo: {
|
|
1597
|
+
name: "workmatic-mcp",
|
|
1598
|
+
version: "0.1.0"
|
|
1599
|
+
}
|
|
1600
|
+
};
|
|
1601
|
+
return isNotification ? null : JSON.stringify({ jsonrpc: "2.0", id: msg.id, result });
|
|
1602
|
+
}
|
|
1603
|
+
case "notifications/initialized": {
|
|
1604
|
+
return null;
|
|
1605
|
+
}
|
|
1606
|
+
case "ping": {
|
|
1607
|
+
return isNotification ? null : JSON.stringify({ jsonrpc: "2.0", id: msg.id, result: {} });
|
|
1608
|
+
}
|
|
1609
|
+
case "tools/list": {
|
|
1610
|
+
const result = {
|
|
1611
|
+
tools: MCP_TOOL_DEFINITIONS
|
|
1612
|
+
};
|
|
1613
|
+
return isNotification ? null : JSON.stringify({ jsonrpc: "2.0", id: msg.id, result });
|
|
1614
|
+
}
|
|
1615
|
+
case "tools/call": {
|
|
1616
|
+
if (!msg.params || typeof msg.params.name !== "string") {
|
|
1617
|
+
return isNotification ? null : JSON.stringify({
|
|
1618
|
+
jsonrpc: "2.0",
|
|
1619
|
+
id: msg.id,
|
|
1620
|
+
error: {
|
|
1621
|
+
code: -32602,
|
|
1622
|
+
message: 'Invalid params: tool "name" is required'
|
|
1623
|
+
}
|
|
1624
|
+
});
|
|
1625
|
+
}
|
|
1626
|
+
const toolName = msg.params.name;
|
|
1627
|
+
const toolArgs = msg.params.arguments ?? {};
|
|
1628
|
+
try {
|
|
1629
|
+
const toolResult = await executeTool(db, toolName, toolArgs, {
|
|
1630
|
+
onJobStatusChanged: notifyJobStatusChanged
|
|
1631
|
+
});
|
|
1632
|
+
const response = {
|
|
1633
|
+
content: [
|
|
1634
|
+
{
|
|
1635
|
+
type: "text",
|
|
1636
|
+
text: JSON.stringify(toolResult, null, 2)
|
|
1637
|
+
}
|
|
1638
|
+
]
|
|
1639
|
+
};
|
|
1640
|
+
return isNotification ? null : JSON.stringify({ jsonrpc: "2.0", id: msg.id, result: response });
|
|
1641
|
+
} catch (err) {
|
|
1642
|
+
const errorMessage = err instanceof Error ? err.message : String(err);
|
|
1643
|
+
const response = {
|
|
1644
|
+
content: [
|
|
1645
|
+
{
|
|
1646
|
+
type: "text",
|
|
1647
|
+
text: errorMessage
|
|
1648
|
+
}
|
|
1649
|
+
],
|
|
1650
|
+
isError: true
|
|
1651
|
+
};
|
|
1652
|
+
return isNotification ? null : JSON.stringify({ jsonrpc: "2.0", id: msg.id, result: response });
|
|
1653
|
+
}
|
|
1654
|
+
}
|
|
1655
|
+
default: {
|
|
1656
|
+
return isNotification ? null : JSON.stringify({
|
|
1657
|
+
jsonrpc: "2.0",
|
|
1658
|
+
id: msg.id,
|
|
1659
|
+
error: {
|
|
1660
|
+
code: -32601,
|
|
1661
|
+
message: `Method not found: ${msg.method}`
|
|
1662
|
+
}
|
|
1663
|
+
});
|
|
1664
|
+
}
|
|
1665
|
+
}
|
|
1666
|
+
}
|
|
1667
|
+
const server = {
|
|
1668
|
+
start() {
|
|
1669
|
+
if (isRunning) return;
|
|
1670
|
+
isRunning = true;
|
|
1671
|
+
rl = readline.createInterface({
|
|
1672
|
+
input,
|
|
1673
|
+
terminal: false
|
|
1674
|
+
});
|
|
1675
|
+
rl.on("line", (line) => {
|
|
1676
|
+
void handleMessage(line).then((response) => {
|
|
1677
|
+
if (response && isRunning) {
|
|
1678
|
+
output.write(response + "\n");
|
|
1679
|
+
}
|
|
1680
|
+
});
|
|
1681
|
+
});
|
|
1682
|
+
},
|
|
1683
|
+
stop() {
|
|
1684
|
+
if (!isRunning) return;
|
|
1685
|
+
isRunning = false;
|
|
1686
|
+
rl.close();
|
|
1687
|
+
rl = null;
|
|
1688
|
+
},
|
|
1689
|
+
handleMessage,
|
|
1690
|
+
on(event, listener) {
|
|
1691
|
+
emitter.on(event, listener);
|
|
1692
|
+
return server;
|
|
1693
|
+
},
|
|
1694
|
+
off(event, listener) {
|
|
1695
|
+
emitter.off(event, listener);
|
|
1696
|
+
return server;
|
|
1697
|
+
}
|
|
1698
|
+
};
|
|
1699
|
+
return server;
|
|
1700
|
+
}
|
|
1701
|
+
|
|
748
1702
|
// src/cli/handlers.ts
|
|
749
1703
|
function printUsage() {
|
|
750
1704
|
console.log(`
|
|
@@ -764,6 +1718,8 @@ Commands:
|
|
|
764
1718
|
pause <db> <queue> Pause a queue (running workers stop claiming)
|
|
765
1719
|
resume <db> <queue> Resume a paused queue
|
|
766
1720
|
transfer <db> <from> <to> Move jobs between queues
|
|
1721
|
+
mcp <db> Start Model Context Protocol (MCP) server
|
|
1722
|
+
|
|
767
1723
|
|
|
768
1724
|
Options:
|
|
769
1725
|
--status=<status> Filter by status (ready|running|done|dead), comma-separated for transfer
|
|
@@ -1237,6 +2193,34 @@ async function cmdTransfer(dbPath, from, to, options) {
|
|
|
1237
2193
|
await db.destroy();
|
|
1238
2194
|
}
|
|
1239
2195
|
}
|
|
2196
|
+
async function cmdMcp(dbPath, options = {}) {
|
|
2197
|
+
const db = createDatabase({ filename: dbPath });
|
|
2198
|
+
const server = createMcpServer({
|
|
2199
|
+
db,
|
|
2200
|
+
input: options.input,
|
|
2201
|
+
output: options.output
|
|
2202
|
+
});
|
|
2203
|
+
server.start();
|
|
2204
|
+
await new Promise((resolve) => {
|
|
2205
|
+
const cleanup = () => {
|
|
2206
|
+
server.stop();
|
|
2207
|
+
if (!options.input) {
|
|
2208
|
+
process.removeListener("SIGINT", cleanup);
|
|
2209
|
+
process.removeListener("SIGTERM", cleanup);
|
|
2210
|
+
process.stdin.removeListener("end", cleanup);
|
|
2211
|
+
}
|
|
2212
|
+
resolve();
|
|
2213
|
+
};
|
|
2214
|
+
if (options.input) {
|
|
2215
|
+
options.input.once("end", cleanup);
|
|
2216
|
+
} else {
|
|
2217
|
+
process.stdin.once("end", cleanup);
|
|
2218
|
+
process.once("SIGINT", cleanup);
|
|
2219
|
+
process.once("SIGTERM", cleanup);
|
|
2220
|
+
}
|
|
2221
|
+
});
|
|
2222
|
+
await db.destroy();
|
|
2223
|
+
}
|
|
1240
2224
|
async function runCommand(command2, dbPath, positionalArgs, options) {
|
|
1241
2225
|
switch (command2) {
|
|
1242
2226
|
case "stats":
|
|
@@ -1281,6 +2265,9 @@ async function runCommand(command2, dbPath, positionalArgs, options) {
|
|
|
1281
2265
|
}
|
|
1282
2266
|
await cmdTransfer(dbPath, positionalArgs[1], positionalArgs[2], options);
|
|
1283
2267
|
break;
|
|
2268
|
+
case "mcp":
|
|
2269
|
+
await cmdMcp(dbPath);
|
|
2270
|
+
break;
|
|
1284
2271
|
default:
|
|
1285
2272
|
throw new Error(`Unknown command: ${command2}`);
|
|
1286
2273
|
}
|
|
@@ -1299,7 +2286,8 @@ var KNOWN_COMMANDS = [
|
|
|
1299
2286
|
"pause",
|
|
1300
2287
|
"resume",
|
|
1301
2288
|
"queues",
|
|
1302
|
-
"transfer"
|
|
2289
|
+
"transfer",
|
|
2290
|
+
"mcp"
|
|
1303
2291
|
];
|
|
1304
2292
|
function isCliCommand(value) {
|
|
1305
2293
|
return KNOWN_COMMANDS.includes(value);
|