workmatic 1.0.7 → 1.1.1
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/README.md +60 -0
- package/dist/cli.cjs +903 -104
- package/dist/cli.cjs.map +1 -1
- package/dist/cli.js +903 -104
- package/dist/cli.js.map +1 -1
- package/dist/index.cjs +256 -54
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +78 -1
- package/dist/index.d.ts +78 -1
- package/dist/index.js +255 -54
- package/dist/index.js.map +1 -1
- package/package.json +5 -3
package/dist/cli.cjs
CHANGED
|
@@ -23,13 +23,752 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
|
|
|
23
23
|
mod
|
|
24
24
|
));
|
|
25
25
|
|
|
26
|
-
// src/cli.ts
|
|
27
|
-
var
|
|
26
|
+
// src/cli/handlers.ts
|
|
27
|
+
var import_better_sqlite32 = __toESM(require("better-sqlite3"), 1);
|
|
28
28
|
var import_fs = require("fs");
|
|
29
|
+
var import_promises = require("stream/promises");
|
|
29
30
|
var import_readline = require("readline");
|
|
30
31
|
var import_cli_table3 = __toESM(require("cli-table3"), 1);
|
|
31
|
-
|
|
32
|
-
|
|
32
|
+
|
|
33
|
+
// src/database.ts
|
|
34
|
+
var import_better_sqlite3 = __toESM(require("better-sqlite3"), 1);
|
|
35
|
+
var import_kysely = require("kysely");
|
|
36
|
+
var kyselyToSqlite = /* @__PURE__ */ new WeakMap();
|
|
37
|
+
function createDatabase(options = {}) {
|
|
38
|
+
let sqliteDb;
|
|
39
|
+
if (options.db) {
|
|
40
|
+
sqliteDb = options.db;
|
|
41
|
+
} else {
|
|
42
|
+
const filename = options.filename ?? ":memory:";
|
|
43
|
+
sqliteDb = new import_better_sqlite3.default(filename);
|
|
44
|
+
}
|
|
45
|
+
sqliteDb.pragma("journal_mode = WAL");
|
|
46
|
+
sqliteDb.pragma("synchronous = NORMAL");
|
|
47
|
+
sqliteDb.pragma("busy_timeout = 5000");
|
|
48
|
+
const db = new import_kysely.Kysely({
|
|
49
|
+
dialect: new import_kysely.SqliteDialect({
|
|
50
|
+
database: sqliteDb
|
|
51
|
+
})
|
|
52
|
+
});
|
|
53
|
+
createSchema(sqliteDb);
|
|
54
|
+
kyselyToSqlite.set(db, sqliteDb);
|
|
55
|
+
return db;
|
|
56
|
+
}
|
|
57
|
+
function createSchema(db) {
|
|
58
|
+
db.exec(`
|
|
59
|
+
CREATE TABLE IF NOT EXISTS workmatic_jobs (
|
|
60
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
61
|
+
public_id TEXT UNIQUE NOT NULL,
|
|
62
|
+
queue TEXT NOT NULL,
|
|
63
|
+
payload TEXT NOT NULL,
|
|
64
|
+
status TEXT NOT NULL DEFAULT 'ready',
|
|
65
|
+
priority INTEGER NOT NULL DEFAULT 0,
|
|
66
|
+
run_at INTEGER NOT NULL,
|
|
67
|
+
attempts INTEGER NOT NULL DEFAULT 0,
|
|
68
|
+
max_attempts INTEGER NOT NULL DEFAULT 3,
|
|
69
|
+
lease_until INTEGER NOT NULL DEFAULT 0,
|
|
70
|
+
created_at INTEGER NOT NULL,
|
|
71
|
+
updated_at INTEGER NOT NULL,
|
|
72
|
+
last_error TEXT
|
|
73
|
+
)
|
|
74
|
+
`);
|
|
75
|
+
db.exec(`
|
|
76
|
+
CREATE INDEX IF NOT EXISTS idx_workmatic_jobs_claim
|
|
77
|
+
ON workmatic_jobs (queue, status, run_at, priority, id)
|
|
78
|
+
`);
|
|
79
|
+
db.exec(`
|
|
80
|
+
CREATE INDEX IF NOT EXISTS idx_workmatic_jobs_lease
|
|
81
|
+
ON workmatic_jobs (status, lease_until)
|
|
82
|
+
`);
|
|
83
|
+
db.exec(`
|
|
84
|
+
CREATE TABLE IF NOT EXISTS workmatic_settings (
|
|
85
|
+
queue TEXT PRIMARY KEY,
|
|
86
|
+
paused INTEGER NOT NULL DEFAULT 0,
|
|
87
|
+
updated_at INTEGER NOT NULL
|
|
88
|
+
)
|
|
89
|
+
`);
|
|
90
|
+
db.exec(`
|
|
91
|
+
UPDATE workmatic_jobs SET status = 'dead' WHERE status = 'failed'
|
|
92
|
+
`);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// src/orchestrator.ts
|
|
96
|
+
var import_kysely4 = require("kysely");
|
|
97
|
+
|
|
98
|
+
// src/client.ts
|
|
99
|
+
var import_nanoid = require("nanoid");
|
|
100
|
+
var import_kysely2 = require("kysely");
|
|
101
|
+
|
|
102
|
+
// src/utils.ts
|
|
103
|
+
var defaultBackoff = (attempts) => {
|
|
104
|
+
return 1e3 * Math.pow(2, attempts);
|
|
105
|
+
};
|
|
106
|
+
function validatePayload(payload) {
|
|
107
|
+
try {
|
|
108
|
+
return JSON.stringify(payload);
|
|
109
|
+
} catch (error) {
|
|
110
|
+
throw new Error(
|
|
111
|
+
`Payload is not JSON-serializable: ${error instanceof Error ? error.message : "Unknown error"}`
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
function parsePayload(json) {
|
|
116
|
+
try {
|
|
117
|
+
return JSON.parse(json);
|
|
118
|
+
} catch (error) {
|
|
119
|
+
throw new Error(
|
|
120
|
+
`Invalid JSON payload: ${error instanceof Error ? error.message : "Unknown error"}`
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
function now() {
|
|
125
|
+
return Date.now();
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// src/client.ts
|
|
129
|
+
function createClient(options) {
|
|
130
|
+
const { db, queue = "default" } = options;
|
|
131
|
+
if (!db) {
|
|
132
|
+
throw new Error("Database instance is required");
|
|
133
|
+
}
|
|
134
|
+
return {
|
|
135
|
+
/**
|
|
136
|
+
* Add a job to the queue
|
|
137
|
+
*/
|
|
138
|
+
async add(payload, opts = {}) {
|
|
139
|
+
const {
|
|
140
|
+
priority = 0,
|
|
141
|
+
delayMs = 0,
|
|
142
|
+
maxAttempts = 3
|
|
143
|
+
} = opts;
|
|
144
|
+
const payloadJson = validatePayload(payload);
|
|
145
|
+
const publicId = (0, import_nanoid.nanoid)();
|
|
146
|
+
const timestamp = now();
|
|
147
|
+
const runAt = timestamp + delayMs;
|
|
148
|
+
await db.insertInto("workmatic_jobs").values({
|
|
149
|
+
public_id: publicId,
|
|
150
|
+
queue,
|
|
151
|
+
payload: payloadJson,
|
|
152
|
+
status: "ready",
|
|
153
|
+
priority,
|
|
154
|
+
run_at: runAt,
|
|
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();
|
|
162
|
+
return { ok: true, id: publicId };
|
|
163
|
+
},
|
|
164
|
+
async addMany(payloads, opts = {}) {
|
|
165
|
+
const {
|
|
166
|
+
priority = 0,
|
|
167
|
+
delayMs = 0,
|
|
168
|
+
maxAttempts = 3
|
|
169
|
+
} = opts;
|
|
170
|
+
if (payloads.length === 0) {
|
|
171
|
+
return { ok: true, ids: [] };
|
|
172
|
+
}
|
|
173
|
+
const timestamp = now();
|
|
174
|
+
const runAt = timestamp + delayMs;
|
|
175
|
+
return await db.transaction().execute(async (trx) => {
|
|
176
|
+
const ids = [];
|
|
177
|
+
const rows = payloads.map((payload) => {
|
|
178
|
+
const payloadJson = validatePayload(payload);
|
|
179
|
+
const publicId = (0, import_nanoid.nanoid)();
|
|
180
|
+
ids.push(publicId);
|
|
181
|
+
return {
|
|
182
|
+
public_id: publicId,
|
|
183
|
+
queue,
|
|
184
|
+
payload: payloadJson,
|
|
185
|
+
status: "ready",
|
|
186
|
+
priority,
|
|
187
|
+
run_at: runAt,
|
|
188
|
+
attempts: 0,
|
|
189
|
+
max_attempts: maxAttempts,
|
|
190
|
+
lease_until: 0,
|
|
191
|
+
created_at: timestamp,
|
|
192
|
+
updated_at: timestamp,
|
|
193
|
+
last_error: null
|
|
194
|
+
};
|
|
195
|
+
});
|
|
196
|
+
await trx.insertInto("workmatic_jobs").values(rows).execute();
|
|
197
|
+
return { ok: true, ids };
|
|
198
|
+
});
|
|
199
|
+
},
|
|
200
|
+
/**
|
|
201
|
+
* Get job statistics for the queue
|
|
202
|
+
*/
|
|
203
|
+
async stats() {
|
|
204
|
+
const result = await db.selectFrom("workmatic_jobs").select([
|
|
205
|
+
"status",
|
|
206
|
+
import_kysely2.sql`count(*)`.as("count")
|
|
207
|
+
]).where("queue", "=", queue).groupBy("status").execute();
|
|
208
|
+
const stats = {
|
|
209
|
+
ready: 0,
|
|
210
|
+
running: 0,
|
|
211
|
+
done: 0,
|
|
212
|
+
dead: 0,
|
|
213
|
+
total: 0
|
|
214
|
+
};
|
|
215
|
+
for (const row of result) {
|
|
216
|
+
const status = row.status;
|
|
217
|
+
const count = Number(row.count);
|
|
218
|
+
if (status in stats) {
|
|
219
|
+
stats[status] = count;
|
|
220
|
+
}
|
|
221
|
+
stats.total += count;
|
|
222
|
+
}
|
|
223
|
+
return stats;
|
|
224
|
+
},
|
|
225
|
+
/**
|
|
226
|
+
* Clear all jobs from the queue
|
|
227
|
+
*/
|
|
228
|
+
async clear(options2 = {}) {
|
|
229
|
+
let query = db.deleteFrom("workmatic_jobs").where("queue", "=", queue);
|
|
230
|
+
if (options2.status) {
|
|
231
|
+
query = query.where("status", "=", options2.status);
|
|
232
|
+
}
|
|
233
|
+
const result = await query.execute();
|
|
234
|
+
return Number(result[0]?.numDeletedRows ?? 0);
|
|
235
|
+
}
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// src/worker.ts
|
|
240
|
+
var import_fastq = __toESM(require("fastq"), 1);
|
|
241
|
+
var import_kysely3 = require("kysely");
|
|
242
|
+
var DEFAULT_WORKER_TIMEOUT_MS = 6e4;
|
|
243
|
+
function parseClaimedRows(result) {
|
|
244
|
+
const rows = result.rows;
|
|
245
|
+
if (!rows || !Array.isArray(rows)) {
|
|
246
|
+
return [];
|
|
247
|
+
}
|
|
248
|
+
return rows.map((row) => ({
|
|
249
|
+
id: row.id,
|
|
250
|
+
public_id: row.public_id,
|
|
251
|
+
queue: row.queue,
|
|
252
|
+
payload: row.payload,
|
|
253
|
+
attempts: row.attempts,
|
|
254
|
+
max_attempts: row.max_attempts,
|
|
255
|
+
priority: row.priority,
|
|
256
|
+
created_at: row.created_at,
|
|
257
|
+
last_error: row.last_error
|
|
258
|
+
}));
|
|
259
|
+
}
|
|
260
|
+
function requireProcessor(processor) {
|
|
261
|
+
if (!processor) {
|
|
262
|
+
throw new Error("No processor set");
|
|
263
|
+
}
|
|
264
|
+
return processor;
|
|
265
|
+
}
|
|
266
|
+
function createWorker(options) {
|
|
267
|
+
const {
|
|
268
|
+
db,
|
|
269
|
+
queue = "default",
|
|
270
|
+
concurrency = 1,
|
|
271
|
+
leaseMs = 3e4,
|
|
272
|
+
pollMs = 1e3,
|
|
273
|
+
timeoutMs = DEFAULT_WORKER_TIMEOUT_MS,
|
|
274
|
+
backoff = defaultBackoff,
|
|
275
|
+
persistState = false,
|
|
276
|
+
autoRestore = true,
|
|
277
|
+
pauseCheckIntervalMs = 300,
|
|
278
|
+
requeueExpiredIntervalMs = 0,
|
|
279
|
+
onPumpError
|
|
280
|
+
} = options;
|
|
281
|
+
if (!db) {
|
|
282
|
+
throw new Error("Database instance is required");
|
|
283
|
+
}
|
|
284
|
+
let running = false;
|
|
285
|
+
let paused = false;
|
|
286
|
+
let processor = null;
|
|
287
|
+
let pumpTimeout = null;
|
|
288
|
+
let fastqQueue = null;
|
|
289
|
+
let lastPauseCheckAt = 0;
|
|
290
|
+
let cachedDbPaused = false;
|
|
291
|
+
let lastRequeueAt = 0;
|
|
292
|
+
function notifyPumpError(error) {
|
|
293
|
+
console.error("[workmatic] Pump error:", error);
|
|
294
|
+
onPumpError?.(error);
|
|
295
|
+
}
|
|
296
|
+
function getStateKey() {
|
|
297
|
+
return `worker_state_${queue}`;
|
|
298
|
+
}
|
|
299
|
+
async function saveState(state) {
|
|
300
|
+
if (!persistState) return;
|
|
301
|
+
const timestamp = now();
|
|
302
|
+
const key = getStateKey();
|
|
303
|
+
await import_kysely3.sql`
|
|
304
|
+
INSERT INTO workmatic_settings (queue, paused, updated_at)
|
|
305
|
+
VALUES (${key}, ${state === "paused" ? 1 : state === "running" ? 2 : 0}, ${timestamp})
|
|
306
|
+
ON CONFLICT(queue) DO UPDATE SET
|
|
307
|
+
paused = ${state === "paused" ? 1 : state === "running" ? 2 : 0},
|
|
308
|
+
updated_at = ${timestamp}
|
|
309
|
+
`.execute(db);
|
|
310
|
+
}
|
|
311
|
+
async function loadState() {
|
|
312
|
+
if (!persistState) return null;
|
|
313
|
+
try {
|
|
314
|
+
const key = getStateKey();
|
|
315
|
+
const result = await db.selectFrom("workmatic_settings").select("paused").where("queue", "=", key).executeTakeFirst();
|
|
316
|
+
if (!result) return null;
|
|
317
|
+
if (result.paused === 2) return "running";
|
|
318
|
+
if (result.paused === 1) return "paused";
|
|
319
|
+
return "stopped";
|
|
320
|
+
} catch {
|
|
321
|
+
return null;
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
async function requeueExpiredLeases() {
|
|
325
|
+
const timestamp = now();
|
|
326
|
+
const result = await db.updateTable("workmatic_jobs").set({
|
|
327
|
+
status: "ready",
|
|
328
|
+
lease_until: 0,
|
|
329
|
+
updated_at: timestamp
|
|
330
|
+
}).where("status", "=", "running").where("lease_until", "<", timestamp).where("lease_until", ">", 0).execute();
|
|
331
|
+
return Number(result[0]?.numUpdatedRows ?? 0);
|
|
332
|
+
}
|
|
333
|
+
async function claimBatch(limit) {
|
|
334
|
+
const timestamp = now();
|
|
335
|
+
const leaseUntil = timestamp + leaseMs;
|
|
336
|
+
return await db.transaction().execute(async (trx) => {
|
|
337
|
+
const result = await import_kysely3.sql`
|
|
338
|
+
UPDATE workmatic_jobs
|
|
339
|
+
SET status = 'running', lease_until = ${leaseUntil}, updated_at = ${timestamp}
|
|
340
|
+
WHERE rowid IN (
|
|
341
|
+
SELECT rowid FROM workmatic_jobs
|
|
342
|
+
WHERE queue = ${queue}
|
|
343
|
+
AND status = 'ready'
|
|
344
|
+
AND run_at <= ${timestamp}
|
|
345
|
+
ORDER BY priority ASC, id ASC
|
|
346
|
+
LIMIT ${limit}
|
|
347
|
+
)
|
|
348
|
+
RETURNING id, public_id, queue, payload, attempts, max_attempts, priority, created_at, last_error
|
|
349
|
+
`.execute(trx);
|
|
350
|
+
return parseClaimedRows(result);
|
|
351
|
+
});
|
|
352
|
+
}
|
|
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
|
+
async function markFailed(jobId, attempts, maxAttempts, error) {
|
|
361
|
+
const timestamp = now();
|
|
362
|
+
const newAttempts = attempts + 1;
|
|
363
|
+
const errorMessage = error.message || String(error);
|
|
364
|
+
if (newAttempts < maxAttempts) {
|
|
365
|
+
const runAt = timestamp + backoff(newAttempts);
|
|
366
|
+
await db.updateTable("workmatic_jobs").set({
|
|
367
|
+
status: "ready",
|
|
368
|
+
attempts: newAttempts,
|
|
369
|
+
run_at: runAt,
|
|
370
|
+
lease_until: 0,
|
|
371
|
+
last_error: errorMessage,
|
|
372
|
+
updated_at: timestamp
|
|
373
|
+
}).where("id", "=", jobId).execute();
|
|
374
|
+
} else {
|
|
375
|
+
await db.updateTable("workmatic_jobs").set({
|
|
376
|
+
status: "dead",
|
|
377
|
+
attempts: newAttempts,
|
|
378
|
+
lease_until: 0,
|
|
379
|
+
last_error: errorMessage,
|
|
380
|
+
updated_at: timestamp
|
|
381
|
+
}).where("id", "=", jobId).execute();
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
async function withTimeout(promise, ms, jobId) {
|
|
385
|
+
let timeoutId;
|
|
386
|
+
const timeoutPromise = new Promise((_, reject) => {
|
|
387
|
+
timeoutId = setTimeout(() => {
|
|
388
|
+
reject(new Error(`Job ${jobId} timed out after ${ms}ms`));
|
|
389
|
+
}, ms);
|
|
390
|
+
});
|
|
391
|
+
try {
|
|
392
|
+
return await Promise.race([promise, timeoutPromise]);
|
|
393
|
+
} finally {
|
|
394
|
+
clearTimeout(timeoutId);
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
async function processJob(claimedJob) {
|
|
398
|
+
const fn = requireProcessor(processor);
|
|
399
|
+
const payload = parsePayload(claimedJob.payload);
|
|
400
|
+
const job = {
|
|
401
|
+
id: claimedJob.public_id,
|
|
402
|
+
queue: claimedJob.queue,
|
|
403
|
+
payload,
|
|
404
|
+
status: "running",
|
|
405
|
+
priority: claimedJob.priority,
|
|
406
|
+
attempts: claimedJob.attempts,
|
|
407
|
+
maxAttempts: claimedJob.max_attempts,
|
|
408
|
+
createdAt: claimedJob.created_at,
|
|
409
|
+
lastError: claimedJob.last_error
|
|
410
|
+
};
|
|
411
|
+
try {
|
|
412
|
+
if (timeoutMs) {
|
|
413
|
+
await withTimeout(fn(job), timeoutMs, job.id);
|
|
414
|
+
} else {
|
|
415
|
+
await fn(job);
|
|
416
|
+
}
|
|
417
|
+
await markDone(claimedJob.id);
|
|
418
|
+
} catch (error) {
|
|
419
|
+
await markFailed(
|
|
420
|
+
claimedJob.id,
|
|
421
|
+
claimedJob.attempts,
|
|
422
|
+
claimedJob.max_attempts,
|
|
423
|
+
error instanceof Error ? error : new Error(String(error))
|
|
424
|
+
);
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
async function isQueuePausedInDb() {
|
|
428
|
+
const setting = await db.selectFrom("workmatic_settings").select("paused").where("queue", "=", queue).executeTakeFirst();
|
|
429
|
+
return setting?.paused === 1;
|
|
430
|
+
}
|
|
431
|
+
async function pump() {
|
|
432
|
+
if (!running) {
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
if (paused) {
|
|
436
|
+
pumpTimeout = setTimeout(pump, pollMs);
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
try {
|
|
440
|
+
const t = now();
|
|
441
|
+
if (t - lastPauseCheckAt >= pauseCheckIntervalMs) {
|
|
442
|
+
lastPauseCheckAt = t;
|
|
443
|
+
cachedDbPaused = await isQueuePausedInDb();
|
|
444
|
+
}
|
|
445
|
+
if (cachedDbPaused) {
|
|
446
|
+
pumpTimeout = setTimeout(pump, pollMs);
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
if (requeueExpiredIntervalMs <= 0 || t - lastRequeueAt >= requeueExpiredIntervalMs) {
|
|
450
|
+
if (requeueExpiredIntervalMs > 0) {
|
|
451
|
+
lastRequeueAt = t;
|
|
452
|
+
}
|
|
453
|
+
await requeueExpiredLeases();
|
|
454
|
+
}
|
|
455
|
+
const batchSize = concurrency * 2;
|
|
456
|
+
const jobs = await claimBatch(batchSize);
|
|
457
|
+
if (jobs.length > 0) {
|
|
458
|
+
for (const job of jobs) {
|
|
459
|
+
fastqQueue.push(job);
|
|
460
|
+
}
|
|
461
|
+
pumpTimeout = setTimeout(pump, 0);
|
|
462
|
+
} else {
|
|
463
|
+
pumpTimeout = setTimeout(pump, pollMs);
|
|
464
|
+
}
|
|
465
|
+
} catch (error) {
|
|
466
|
+
notifyPumpError(error);
|
|
467
|
+
pumpTimeout = setTimeout(pump, pollMs);
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
const worker = {
|
|
471
|
+
process(fn) {
|
|
472
|
+
processor = fn;
|
|
473
|
+
},
|
|
474
|
+
start() {
|
|
475
|
+
if (running) {
|
|
476
|
+
return;
|
|
477
|
+
}
|
|
478
|
+
if (!processor) {
|
|
479
|
+
throw new Error("No processor set. Call process() before start()");
|
|
480
|
+
}
|
|
481
|
+
running = true;
|
|
482
|
+
paused = false;
|
|
483
|
+
fastqQueue = import_fastq.default.promise(processJob, concurrency);
|
|
484
|
+
saveState("running").catch(() => {
|
|
485
|
+
});
|
|
486
|
+
pump();
|
|
487
|
+
},
|
|
488
|
+
async stop() {
|
|
489
|
+
if (!running) {
|
|
490
|
+
return;
|
|
491
|
+
}
|
|
492
|
+
running = false;
|
|
493
|
+
if (pumpTimeout) {
|
|
494
|
+
clearTimeout(pumpTimeout);
|
|
495
|
+
pumpTimeout = null;
|
|
496
|
+
}
|
|
497
|
+
if (fastqQueue) {
|
|
498
|
+
await fastqQueue.drained();
|
|
499
|
+
fastqQueue = null;
|
|
500
|
+
}
|
|
501
|
+
await saveState("stopped");
|
|
502
|
+
},
|
|
503
|
+
pause() {
|
|
504
|
+
paused = true;
|
|
505
|
+
saveState("paused").catch(() => {
|
|
506
|
+
});
|
|
507
|
+
},
|
|
508
|
+
resume() {
|
|
509
|
+
paused = false;
|
|
510
|
+
saveState("running").catch(() => {
|
|
511
|
+
});
|
|
512
|
+
},
|
|
513
|
+
async stats() {
|
|
514
|
+
const result = await db.selectFrom("workmatic_jobs").select([
|
|
515
|
+
"status",
|
|
516
|
+
import_kysely3.sql`count(*)`.as("count")
|
|
517
|
+
]).where("queue", "=", queue).groupBy("status").execute();
|
|
518
|
+
const stats = {
|
|
519
|
+
ready: 0,
|
|
520
|
+
running: 0,
|
|
521
|
+
done: 0,
|
|
522
|
+
dead: 0,
|
|
523
|
+
total: 0
|
|
524
|
+
};
|
|
525
|
+
for (const row of result) {
|
|
526
|
+
const status = row.status;
|
|
527
|
+
const count = Number(row.count);
|
|
528
|
+
if (status in stats) {
|
|
529
|
+
stats[status] = count;
|
|
530
|
+
}
|
|
531
|
+
stats.total += count;
|
|
532
|
+
}
|
|
533
|
+
return stats;
|
|
534
|
+
},
|
|
535
|
+
get isRunning() {
|
|
536
|
+
return running;
|
|
537
|
+
},
|
|
538
|
+
get isPaused() {
|
|
539
|
+
return paused;
|
|
540
|
+
},
|
|
541
|
+
get queue() {
|
|
542
|
+
return queue;
|
|
543
|
+
},
|
|
544
|
+
async restoreState() {
|
|
545
|
+
const state = await loadState();
|
|
546
|
+
if (state === "running" && processor) {
|
|
547
|
+
this.start();
|
|
548
|
+
} else if (state === "paused" && processor) {
|
|
549
|
+
this.start();
|
|
550
|
+
this.pause();
|
|
551
|
+
}
|
|
552
|
+
return state;
|
|
553
|
+
},
|
|
554
|
+
async clear(options2 = {}) {
|
|
555
|
+
let query = db.deleteFrom("workmatic_jobs").where("queue", "=", queue);
|
|
556
|
+
if (options2.status) {
|
|
557
|
+
query = query.where("status", "=", options2.status);
|
|
558
|
+
}
|
|
559
|
+
const result = await query.execute();
|
|
560
|
+
return Number(result[0]?.numDeletedRows ?? 0);
|
|
561
|
+
}
|
|
562
|
+
};
|
|
563
|
+
if (persistState && autoRestore) {
|
|
564
|
+
setImmediate(async () => {
|
|
565
|
+
if (processor) {
|
|
566
|
+
await worker.restoreState();
|
|
567
|
+
}
|
|
568
|
+
});
|
|
569
|
+
}
|
|
570
|
+
return worker;
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
// src/orchestrator.ts
|
|
574
|
+
var DEFAULT_TRANSFER_STATUSES = ["ready", "dead"];
|
|
575
|
+
var DEFAULT_TRANSFER_LIMIT = 1e4;
|
|
576
|
+
function rowsUpdated(result) {
|
|
577
|
+
const row = result[0];
|
|
578
|
+
return Number(row?.numUpdatedRows ?? 0);
|
|
579
|
+
}
|
|
580
|
+
function normalizeStatuses(status) {
|
|
581
|
+
if (!status) return [...DEFAULT_TRANSFER_STATUSES];
|
|
582
|
+
return Array.isArray(status) ? status : [status];
|
|
583
|
+
}
|
|
584
|
+
function createOrchestrator(options) {
|
|
585
|
+
const { db } = options;
|
|
586
|
+
if (!db) {
|
|
587
|
+
throw new Error("Database instance is required");
|
|
588
|
+
}
|
|
589
|
+
const registry = /* @__PURE__ */ new Map();
|
|
590
|
+
function ensureEntry(queue) {
|
|
591
|
+
let entry = registry.get(queue);
|
|
592
|
+
if (!entry) {
|
|
593
|
+
entry = { client: createClient({ db, queue }) };
|
|
594
|
+
registry.set(queue, entry);
|
|
595
|
+
}
|
|
596
|
+
return entry;
|
|
597
|
+
}
|
|
598
|
+
async function setQueuePaused(queue, paused) {
|
|
599
|
+
const timestamp = now();
|
|
600
|
+
const value = paused ? 1 : 0;
|
|
601
|
+
await import_kysely4.sql`
|
|
602
|
+
INSERT INTO workmatic_settings (queue, paused, updated_at)
|
|
603
|
+
VALUES (${queue}, ${value}, ${timestamp})
|
|
604
|
+
ON CONFLICT(queue) DO UPDATE SET
|
|
605
|
+
paused = ${value},
|
|
606
|
+
updated_at = ${timestamp}
|
|
607
|
+
`.execute(db);
|
|
608
|
+
}
|
|
609
|
+
async function selectJobIds(from, status, limit) {
|
|
610
|
+
const rows = await db.selectFrom("workmatic_jobs").select("id").where("queue", "=", from).where("status", "=", status).orderBy("priority", "asc").orderBy("id", "asc").limit(limit).execute();
|
|
611
|
+
return rows.map((r) => r.id);
|
|
612
|
+
}
|
|
613
|
+
async function transferReady(from, to, limit) {
|
|
614
|
+
const ids = await selectJobIds(from, "ready", limit);
|
|
615
|
+
if (ids.length === 0) return 0;
|
|
616
|
+
const timestamp = now();
|
|
617
|
+
const result = await db.updateTable("workmatic_jobs").set({ queue: to, updated_at: timestamp }).where("id", "in", ids).execute();
|
|
618
|
+
return rowsUpdated(result);
|
|
619
|
+
}
|
|
620
|
+
async function transferDead(from, to, limit, resetForRetry) {
|
|
621
|
+
const ids = await selectJobIds(from, "dead", limit);
|
|
622
|
+
if (ids.length === 0) return 0;
|
|
623
|
+
const timestamp = now();
|
|
624
|
+
if (resetForRetry) {
|
|
625
|
+
const result2 = await db.updateTable("workmatic_jobs").set({
|
|
626
|
+
queue: to,
|
|
627
|
+
status: "ready",
|
|
628
|
+
attempts: 0,
|
|
629
|
+
lease_until: 0,
|
|
630
|
+
last_error: null,
|
|
631
|
+
updated_at: timestamp,
|
|
632
|
+
run_at: timestamp
|
|
633
|
+
}).where("id", "in", ids).execute();
|
|
634
|
+
return rowsUpdated(result2);
|
|
635
|
+
}
|
|
636
|
+
const result = await db.updateTable("workmatic_jobs").set({ queue: to, updated_at: timestamp }).where("id", "in", ids).execute();
|
|
637
|
+
return rowsUpdated(result);
|
|
638
|
+
}
|
|
639
|
+
async function transferOtherStatus(from, to, status, limit) {
|
|
640
|
+
const ids = await selectJobIds(from, status, limit);
|
|
641
|
+
if (ids.length === 0) return 0;
|
|
642
|
+
const timestamp = now();
|
|
643
|
+
const result = await db.updateTable("workmatic_jobs").set({ queue: to, updated_at: timestamp }).where("id", "in", ids).execute();
|
|
644
|
+
return rowsUpdated(result);
|
|
645
|
+
}
|
|
646
|
+
const orchestrator = {
|
|
647
|
+
register(queue, opts = {}) {
|
|
648
|
+
const client = createClient({ db, queue });
|
|
649
|
+
let worker;
|
|
650
|
+
if (opts.worker) {
|
|
651
|
+
worker = createWorker({ db, queue, ...opts.worker });
|
|
652
|
+
}
|
|
653
|
+
registry.set(queue, { client, worker });
|
|
654
|
+
return client;
|
|
655
|
+
},
|
|
656
|
+
client(queue) {
|
|
657
|
+
return ensureEntry(queue).client;
|
|
658
|
+
},
|
|
659
|
+
worker(queue) {
|
|
660
|
+
const entry = registry.get(queue);
|
|
661
|
+
if (!entry?.worker) {
|
|
662
|
+
throw new Error(`No worker registered for queue "${queue}"`);
|
|
663
|
+
}
|
|
664
|
+
return entry.worker;
|
|
665
|
+
},
|
|
666
|
+
workers() {
|
|
667
|
+
return [...registry.values()].map((e) => e.worker).filter((w) => w !== void 0);
|
|
668
|
+
},
|
|
669
|
+
async queues() {
|
|
670
|
+
const rows = await db.selectFrom("workmatic_jobs").select("queue").distinct().execute();
|
|
671
|
+
const names = new Set(rows.map((r) => r.queue));
|
|
672
|
+
for (const name of registry.keys()) {
|
|
673
|
+
names.add(name);
|
|
674
|
+
}
|
|
675
|
+
return [...names].sort();
|
|
676
|
+
},
|
|
677
|
+
process(queue, fn) {
|
|
678
|
+
this.worker(queue).process(fn);
|
|
679
|
+
},
|
|
680
|
+
startAll() {
|
|
681
|
+
for (const entry of registry.values()) {
|
|
682
|
+
entry.worker?.start();
|
|
683
|
+
}
|
|
684
|
+
},
|
|
685
|
+
async stopAll() {
|
|
686
|
+
const stops = [...registry.values()].map((e) => e.worker?.stop()).filter((p) => p !== void 0);
|
|
687
|
+
await Promise.all(stops);
|
|
688
|
+
},
|
|
689
|
+
async pause(queue) {
|
|
690
|
+
const entry = registry.get(queue);
|
|
691
|
+
entry?.worker?.pause();
|
|
692
|
+
await setQueuePaused(queue, true);
|
|
693
|
+
},
|
|
694
|
+
async resume(queue) {
|
|
695
|
+
const entry = registry.get(queue);
|
|
696
|
+
entry?.worker?.resume();
|
|
697
|
+
await setQueuePaused(queue, false);
|
|
698
|
+
},
|
|
699
|
+
async isPaused(queue) {
|
|
700
|
+
const setting = await db.selectFrom("workmatic_settings").select("paused").where("queue", "=", queue).executeTakeFirst();
|
|
701
|
+
return setting?.paused === 1;
|
|
702
|
+
},
|
|
703
|
+
async stats(queueName) {
|
|
704
|
+
const names = queueName ? [queueName] : await this.queues();
|
|
705
|
+
const result = {};
|
|
706
|
+
for (const name of names) {
|
|
707
|
+
result[name] = await ensureEntry(name).client.stats();
|
|
708
|
+
}
|
|
709
|
+
return result;
|
|
710
|
+
},
|
|
711
|
+
async transfer(opts) {
|
|
712
|
+
const { from, to, resetForRetry = false } = opts;
|
|
713
|
+
if (from === to) {
|
|
714
|
+
return { moved: 0 };
|
|
715
|
+
}
|
|
716
|
+
const statuses = normalizeStatuses(opts.status);
|
|
717
|
+
let remaining = opts.limit ?? DEFAULT_TRANSFER_LIMIT;
|
|
718
|
+
let moved = 0;
|
|
719
|
+
if (statuses.includes("ready") && remaining > 0) {
|
|
720
|
+
const n = await transferReady(from, to, remaining);
|
|
721
|
+
moved += n;
|
|
722
|
+
remaining -= n;
|
|
723
|
+
}
|
|
724
|
+
if (statuses.includes("dead") && remaining > 0) {
|
|
725
|
+
const n = await transferDead(from, to, remaining, resetForRetry);
|
|
726
|
+
moved += n;
|
|
727
|
+
remaining -= n;
|
|
728
|
+
}
|
|
729
|
+
for (const status of statuses) {
|
|
730
|
+
if (status === "ready" || status === "dead" || remaining <= 0) continue;
|
|
731
|
+
const n = await transferOtherStatus(from, to, status, remaining);
|
|
732
|
+
moved += n;
|
|
733
|
+
remaining -= n;
|
|
734
|
+
}
|
|
735
|
+
return { moved };
|
|
736
|
+
},
|
|
737
|
+
async moveJob(publicId, toQueue, opts = {}) {
|
|
738
|
+
const allowed = normalizeStatuses(opts.status);
|
|
739
|
+
const row = await db.selectFrom("workmatic_jobs").select(["id", "queue", "status"]).where("public_id", "=", publicId).executeTakeFirst();
|
|
740
|
+
if (!row) {
|
|
741
|
+
throw new Error(`Job not found: ${publicId}`);
|
|
742
|
+
}
|
|
743
|
+
if (row.queue === toQueue) {
|
|
744
|
+
return;
|
|
745
|
+
}
|
|
746
|
+
if (!allowed.includes(row.status)) {
|
|
747
|
+
throw new Error(
|
|
748
|
+
`Job ${publicId} has status "${row.status}" and cannot be moved (allowed: ${allowed.join(", ")})`
|
|
749
|
+
);
|
|
750
|
+
}
|
|
751
|
+
const timestamp = now();
|
|
752
|
+
const resetForRetry = opts.resetForRetry ?? false;
|
|
753
|
+
if (row.status === "dead" && resetForRetry) {
|
|
754
|
+
await db.updateTable("workmatic_jobs").set({
|
|
755
|
+
queue: toQueue,
|
|
756
|
+
status: "ready",
|
|
757
|
+
attempts: 0,
|
|
758
|
+
lease_until: 0,
|
|
759
|
+
last_error: null,
|
|
760
|
+
updated_at: timestamp,
|
|
761
|
+
run_at: timestamp
|
|
762
|
+
}).where("public_id", "=", publicId).execute();
|
|
763
|
+
return;
|
|
764
|
+
}
|
|
765
|
+
await db.updateTable("workmatic_jobs").set({ queue: toQueue, updated_at: timestamp }).where("public_id", "=", publicId).execute();
|
|
766
|
+
}
|
|
767
|
+
};
|
|
768
|
+
return orchestrator;
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
// src/cli/handlers.ts
|
|
33
772
|
function printUsage() {
|
|
34
773
|
console.log(`
|
|
35
774
|
Workmatic CLI - Job Queue Management
|
|
@@ -47,20 +786,18 @@ Commands:
|
|
|
47
786
|
retry <db> [--status=dead] Retry dead jobs (reset to ready)
|
|
48
787
|
pause <db> <queue> Pause a queue (running workers stop claiming)
|
|
49
788
|
resume <db> <queue> Resume a paused queue
|
|
789
|
+
transfer <db> <from> <to> Move jobs between queues
|
|
50
790
|
|
|
51
791
|
Options:
|
|
52
|
-
--status=<status> Filter by status (ready|running|done|dead)
|
|
792
|
+
--status=<status> Filter by status (ready|running|done|dead), comma-separated for transfer
|
|
53
793
|
--queue=<queue> Filter by queue name
|
|
54
|
-
--limit=<n> Limit number of results (default: 100)
|
|
794
|
+
--limit=<n> Limit number of results or jobs to transfer (default: 100 / 10000)
|
|
795
|
+
--retry When transferring dead jobs, reset them to ready for retry
|
|
55
796
|
|
|
56
797
|
Examples:
|
|
57
798
|
workmatic stats ./jobs.db
|
|
58
799
|
workmatic list ./jobs.db --status=dead --limit=10
|
|
59
|
-
workmatic
|
|
60
|
-
workmatic export ./jobs.db --status=dead > dead-jobs.csv
|
|
61
|
-
workmatic import ./jobs.db backup.csv
|
|
62
|
-
workmatic purge ./jobs.db --status=done
|
|
63
|
-
workmatic retry ./jobs.db --status=dead
|
|
800
|
+
workmatic transfer ./jobs.db emails retry-emails --status=dead --retry
|
|
64
801
|
workmatic pause ./jobs.db emails
|
|
65
802
|
workmatic resume ./jobs.db emails
|
|
66
803
|
`);
|
|
@@ -118,27 +855,25 @@ function parseCSVLine(line) {
|
|
|
118
855
|
return result;
|
|
119
856
|
}
|
|
120
857
|
async function cmdStats(dbPath) {
|
|
121
|
-
const db = new
|
|
858
|
+
const db = new import_better_sqlite32.default(dbPath, { readonly: true });
|
|
122
859
|
try {
|
|
123
|
-
const stats = db.prepare(
|
|
860
|
+
const stats = db.prepare(
|
|
861
|
+
`
|
|
124
862
|
SELECT status, COUNT(*) as count
|
|
125
863
|
FROM workmatic_jobs
|
|
126
864
|
GROUP BY status
|
|
127
|
-
`
|
|
128
|
-
|
|
865
|
+
`
|
|
866
|
+
).all();
|
|
867
|
+
const queues = db.prepare(
|
|
868
|
+
`
|
|
129
869
|
SELECT queue, COUNT(*) as count
|
|
130
870
|
FROM workmatic_jobs
|
|
131
871
|
GROUP BY queue
|
|
132
|
-
`
|
|
872
|
+
`
|
|
873
|
+
).all();
|
|
133
874
|
const total = stats.reduce((sum, s) => sum + s.count, 0);
|
|
134
875
|
console.log("\n\u{1F4CA} Job Statistics\n");
|
|
135
876
|
const statusOrder = ["ready", "running", "done", "dead"];
|
|
136
|
-
const statusEmoji = {
|
|
137
|
-
ready: "\u23F3",
|
|
138
|
-
running: "\u25B6\uFE0F",
|
|
139
|
-
done: "\u2705",
|
|
140
|
-
dead: "\u{1F480}"
|
|
141
|
-
};
|
|
142
877
|
const statusTable = new import_cli_table3.default({
|
|
143
878
|
head: ["", "Status", "Count"],
|
|
144
879
|
style: { head: ["cyan"], border: ["gray"] },
|
|
@@ -147,7 +882,7 @@ async function cmdStats(dbPath) {
|
|
|
147
882
|
for (const status of statusOrder) {
|
|
148
883
|
const stat = stats.find((s) => s.status === status);
|
|
149
884
|
const count = stat?.count ?? 0;
|
|
150
|
-
const emoji =
|
|
885
|
+
const emoji = jobStatusEmoji(status);
|
|
151
886
|
statusTable.push([emoji, status, count.toLocaleString()]);
|
|
152
887
|
}
|
|
153
888
|
statusTable.push([{ colSpan: 2, content: "Total", hAlign: "right" }, total.toLocaleString()]);
|
|
@@ -170,7 +905,7 @@ async function cmdStats(dbPath) {
|
|
|
170
905
|
}
|
|
171
906
|
}
|
|
172
907
|
async function cmdList(dbPath, options) {
|
|
173
|
-
const db = new
|
|
908
|
+
const db = new import_better_sqlite32.default(dbPath, { readonly: true });
|
|
174
909
|
try {
|
|
175
910
|
let query = "SELECT public_id, queue, status, priority, attempts, max_attempts, created_at, last_error FROM workmatic_jobs WHERE 1=1";
|
|
176
911
|
const params = [];
|
|
@@ -189,12 +924,6 @@ async function cmdList(dbPath, options) {
|
|
|
189
924
|
console.log("No jobs found.");
|
|
190
925
|
return;
|
|
191
926
|
}
|
|
192
|
-
const statusEmoji = {
|
|
193
|
-
ready: "\u23F3",
|
|
194
|
-
running: "\u25B6\uFE0F",
|
|
195
|
-
done: "\u2705",
|
|
196
|
-
dead: "\u{1F480}"
|
|
197
|
-
};
|
|
198
927
|
const table = new import_cli_table3.default({
|
|
199
928
|
head: ["ID", "Queue", "Status", "Pri", "Attempts", "Created"],
|
|
200
929
|
style: { head: ["cyan"], border: ["gray"] },
|
|
@@ -203,7 +932,7 @@ async function cmdList(dbPath, options) {
|
|
|
203
932
|
wordWrap: true
|
|
204
933
|
});
|
|
205
934
|
for (const job of jobs) {
|
|
206
|
-
const emoji =
|
|
935
|
+
const emoji = jobStatusEmoji(job.status);
|
|
207
936
|
table.push([
|
|
208
937
|
job.public_id.slice(0, 21),
|
|
209
938
|
job.queue.slice(0, 14),
|
|
@@ -223,7 +952,7 @@ async function cmdList(dbPath, options) {
|
|
|
223
952
|
}
|
|
224
953
|
}
|
|
225
954
|
async function cmdExport(dbPath, outputPath, options) {
|
|
226
|
-
const db = new
|
|
955
|
+
const db = new import_better_sqlite32.default(dbPath, { readonly: true });
|
|
227
956
|
try {
|
|
228
957
|
let query = "SELECT * FROM workmatic_jobs WHERE 1=1";
|
|
229
958
|
const params = [];
|
|
@@ -266,7 +995,9 @@ async function cmdExport(dbPath, outputPath, options) {
|
|
|
266
995
|
}
|
|
267
996
|
}
|
|
268
997
|
if (outputPath) {
|
|
269
|
-
output
|
|
998
|
+
const stream = output;
|
|
999
|
+
stream.end();
|
|
1000
|
+
await (0, import_promises.finished)(stream);
|
|
270
1001
|
console.error(`\u2705 Exported ${jobs.length} jobs to ${outputPath}`);
|
|
271
1002
|
}
|
|
272
1003
|
} finally {
|
|
@@ -274,7 +1005,7 @@ async function cmdExport(dbPath, outputPath, options) {
|
|
|
274
1005
|
}
|
|
275
1006
|
}
|
|
276
1007
|
async function cmdImport(dbPath, inputPath) {
|
|
277
|
-
const db = new
|
|
1008
|
+
const db = new import_better_sqlite32.default(dbPath);
|
|
278
1009
|
db.exec(`
|
|
279
1010
|
CREATE TABLE IF NOT EXISTS workmatic_jobs (
|
|
280
1011
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
@@ -355,11 +1086,9 @@ async function cmdImport(dbPath, inputPath) {
|
|
|
355
1086
|
}
|
|
356
1087
|
async function cmdPurge(dbPath, options) {
|
|
357
1088
|
if (!options.status) {
|
|
358
|
-
|
|
359
|
-
console.error("Example: workmatic purge ./jobs.db --status=done");
|
|
360
|
-
process.exit(1);
|
|
1089
|
+
throw new Error("--status is required for purge command");
|
|
361
1090
|
}
|
|
362
|
-
const db = new
|
|
1091
|
+
const db = new import_better_sqlite32.default(dbPath);
|
|
363
1092
|
try {
|
|
364
1093
|
const result = db.prepare("DELETE FROM workmatic_jobs WHERE status = ?").run(options.status);
|
|
365
1094
|
console.log(`\u{1F5D1}\uFE0F Deleted ${result.changes} jobs with status '${options.status}'`);
|
|
@@ -369,10 +1098,11 @@ async function cmdPurge(dbPath, options) {
|
|
|
369
1098
|
}
|
|
370
1099
|
async function cmdRetry(dbPath, options) {
|
|
371
1100
|
const status = options.status || "dead";
|
|
372
|
-
const db = new
|
|
1101
|
+
const db = new import_better_sqlite32.default(dbPath);
|
|
373
1102
|
try {
|
|
374
|
-
const
|
|
375
|
-
const result = db.prepare(
|
|
1103
|
+
const ts = Date.now();
|
|
1104
|
+
const result = db.prepare(
|
|
1105
|
+
`
|
|
376
1106
|
UPDATE workmatic_jobs
|
|
377
1107
|
SET status = 'ready',
|
|
378
1108
|
attempts = 0,
|
|
@@ -380,14 +1110,15 @@ async function cmdRetry(dbPath, options) {
|
|
|
380
1110
|
lease_until = 0,
|
|
381
1111
|
updated_at = ?
|
|
382
1112
|
WHERE status = ?
|
|
383
|
-
`
|
|
1113
|
+
`
|
|
1114
|
+
).run(ts, ts, status);
|
|
384
1115
|
console.log(`\u{1F504} Reset ${result.changes} jobs from '${status}' to 'ready'`);
|
|
385
1116
|
} finally {
|
|
386
1117
|
db.close();
|
|
387
1118
|
}
|
|
388
1119
|
}
|
|
389
1120
|
async function cmdPause(dbPath, queueName) {
|
|
390
|
-
const db = new
|
|
1121
|
+
const db = new import_better_sqlite32.default(dbPath);
|
|
391
1122
|
try {
|
|
392
1123
|
db.exec(`
|
|
393
1124
|
CREATE TABLE IF NOT EXISTS workmatic_settings (
|
|
@@ -396,12 +1127,14 @@ async function cmdPause(dbPath, queueName) {
|
|
|
396
1127
|
updated_at INTEGER NOT NULL
|
|
397
1128
|
)
|
|
398
1129
|
`);
|
|
399
|
-
const
|
|
400
|
-
db.prepare(
|
|
1130
|
+
const ts = Date.now();
|
|
1131
|
+
db.prepare(
|
|
1132
|
+
`
|
|
401
1133
|
INSERT INTO workmatic_settings (queue, paused, updated_at)
|
|
402
1134
|
VALUES (?, 1, ?)
|
|
403
1135
|
ON CONFLICT(queue) DO UPDATE SET paused = 1, updated_at = ?
|
|
404
|
-
`
|
|
1136
|
+
`
|
|
1137
|
+
).run(queueName, ts, ts);
|
|
405
1138
|
console.log(`\u23F8\uFE0F Paused queue '${queueName}'`);
|
|
406
1139
|
console.log(` Running workers will stop claiming new jobs.`);
|
|
407
1140
|
} finally {
|
|
@@ -409,7 +1142,7 @@ async function cmdPause(dbPath, queueName) {
|
|
|
409
1142
|
}
|
|
410
1143
|
}
|
|
411
1144
|
async function cmdResume(dbPath, queueName) {
|
|
412
|
-
const db = new
|
|
1145
|
+
const db = new import_better_sqlite32.default(dbPath);
|
|
413
1146
|
try {
|
|
414
1147
|
db.exec(`
|
|
415
1148
|
CREATE TABLE IF NOT EXISTS workmatic_settings (
|
|
@@ -418,12 +1151,14 @@ async function cmdResume(dbPath, queueName) {
|
|
|
418
1151
|
updated_at INTEGER NOT NULL
|
|
419
1152
|
)
|
|
420
1153
|
`);
|
|
421
|
-
const
|
|
422
|
-
db.prepare(
|
|
1154
|
+
const ts = Date.now();
|
|
1155
|
+
db.prepare(
|
|
1156
|
+
`
|
|
423
1157
|
INSERT INTO workmatic_settings (queue, paused, updated_at)
|
|
424
1158
|
VALUES (?, 0, ?)
|
|
425
1159
|
ON CONFLICT(queue) DO UPDATE SET paused = 0, updated_at = ?
|
|
426
|
-
`
|
|
1160
|
+
`
|
|
1161
|
+
).run(queueName, ts, ts);
|
|
427
1162
|
console.log(`\u25B6\uFE0F Resumed queue '${queueName}'`);
|
|
428
1163
|
console.log(` Workers will start claiming jobs again.`);
|
|
429
1164
|
} finally {
|
|
@@ -431,14 +1166,17 @@ async function cmdResume(dbPath, queueName) {
|
|
|
431
1166
|
}
|
|
432
1167
|
}
|
|
433
1168
|
async function cmdQueues(dbPath) {
|
|
434
|
-
const db = new
|
|
1169
|
+
const db = new import_better_sqlite32.default(dbPath, { readonly: true });
|
|
435
1170
|
try {
|
|
436
|
-
const settingsExists = db.prepare(
|
|
1171
|
+
const settingsExists = db.prepare(
|
|
1172
|
+
`
|
|
437
1173
|
SELECT name FROM sqlite_master WHERE type='table' AND name='workmatic_settings'
|
|
438
|
-
`
|
|
1174
|
+
`
|
|
1175
|
+
).get();
|
|
439
1176
|
let queues;
|
|
440
1177
|
if (settingsExists) {
|
|
441
|
-
queues = db.prepare(
|
|
1178
|
+
queues = db.prepare(
|
|
1179
|
+
`
|
|
442
1180
|
SELECT
|
|
443
1181
|
j.queue,
|
|
444
1182
|
COUNT(*) as total,
|
|
@@ -448,9 +1186,11 @@ async function cmdQueues(dbPath) {
|
|
|
448
1186
|
FROM workmatic_jobs j
|
|
449
1187
|
LEFT JOIN workmatic_settings s ON j.queue = s.queue
|
|
450
1188
|
GROUP BY j.queue
|
|
451
|
-
`
|
|
1189
|
+
`
|
|
1190
|
+
).all();
|
|
452
1191
|
} else {
|
|
453
|
-
queues = db.prepare(
|
|
1192
|
+
queues = db.prepare(
|
|
1193
|
+
`
|
|
454
1194
|
SELECT
|
|
455
1195
|
queue,
|
|
456
1196
|
COUNT(*) as total,
|
|
@@ -459,7 +1199,8 @@ async function cmdQueues(dbPath) {
|
|
|
459
1199
|
0 as paused
|
|
460
1200
|
FROM workmatic_jobs
|
|
461
1201
|
GROUP BY queue
|
|
462
|
-
`
|
|
1202
|
+
`
|
|
1203
|
+
).all();
|
|
463
1204
|
}
|
|
464
1205
|
if (queues.length === 0) {
|
|
465
1206
|
console.log("No queues found.");
|
|
@@ -487,11 +1228,115 @@ async function cmdQueues(dbPath) {
|
|
|
487
1228
|
db.close();
|
|
488
1229
|
}
|
|
489
1230
|
}
|
|
1231
|
+
var JOB_STATUS_EMOJI = {
|
|
1232
|
+
ready: "\u23F3",
|
|
1233
|
+
running: "\u25B6\uFE0F",
|
|
1234
|
+
done: "\u2705",
|
|
1235
|
+
dead: "\u{1F480}"
|
|
1236
|
+
};
|
|
1237
|
+
function jobStatusEmoji(status) {
|
|
1238
|
+
return JOB_STATUS_EMOJI[status] || "";
|
|
1239
|
+
}
|
|
1240
|
+
function parseTransferStatuses(raw) {
|
|
1241
|
+
if (!raw) return ["ready", "dead"];
|
|
1242
|
+
return raw.split(",").map((s) => s.trim());
|
|
1243
|
+
}
|
|
1244
|
+
async function cmdTransfer(dbPath, from, to, options) {
|
|
1245
|
+
const db = createDatabase({ filename: dbPath });
|
|
1246
|
+
try {
|
|
1247
|
+
const orch = createOrchestrator({ db });
|
|
1248
|
+
const status = parseTransferStatuses(options.status);
|
|
1249
|
+
const limit = options.limit ? parseInt(options.limit, 10) : void 0;
|
|
1250
|
+
const resetForRetry = options.retry === "true";
|
|
1251
|
+
const { moved } = await orch.transfer({
|
|
1252
|
+
from,
|
|
1253
|
+
to,
|
|
1254
|
+
status,
|
|
1255
|
+
limit,
|
|
1256
|
+
resetForRetry
|
|
1257
|
+
});
|
|
1258
|
+
console.log(`\u2194\uFE0F Moved ${moved} job(s) from '${from}' to '${to}'`);
|
|
1259
|
+
} finally {
|
|
1260
|
+
await db.destroy();
|
|
1261
|
+
}
|
|
1262
|
+
}
|
|
1263
|
+
async function runCommand(command2, dbPath, positionalArgs, options) {
|
|
1264
|
+
switch (command2) {
|
|
1265
|
+
case "stats":
|
|
1266
|
+
await cmdStats(dbPath);
|
|
1267
|
+
break;
|
|
1268
|
+
case "list":
|
|
1269
|
+
await cmdList(dbPath, options);
|
|
1270
|
+
break;
|
|
1271
|
+
case "export":
|
|
1272
|
+
await cmdExport(dbPath, positionalArgs[1], options);
|
|
1273
|
+
break;
|
|
1274
|
+
case "import":
|
|
1275
|
+
if (!positionalArgs[1]) {
|
|
1276
|
+
throw new Error("Input CSV file is required");
|
|
1277
|
+
}
|
|
1278
|
+
await cmdImport(dbPath, positionalArgs[1]);
|
|
1279
|
+
break;
|
|
1280
|
+
case "purge":
|
|
1281
|
+
await cmdPurge(dbPath, options);
|
|
1282
|
+
break;
|
|
1283
|
+
case "retry":
|
|
1284
|
+
await cmdRetry(dbPath, options);
|
|
1285
|
+
break;
|
|
1286
|
+
case "pause":
|
|
1287
|
+
if (!positionalArgs[1]) {
|
|
1288
|
+
throw new Error("Queue name is required");
|
|
1289
|
+
}
|
|
1290
|
+
await cmdPause(dbPath, positionalArgs[1]);
|
|
1291
|
+
break;
|
|
1292
|
+
case "resume":
|
|
1293
|
+
if (!positionalArgs[1]) {
|
|
1294
|
+
throw new Error("Queue name is required");
|
|
1295
|
+
}
|
|
1296
|
+
await cmdResume(dbPath, positionalArgs[1]);
|
|
1297
|
+
break;
|
|
1298
|
+
case "queues":
|
|
1299
|
+
await cmdQueues(dbPath);
|
|
1300
|
+
break;
|
|
1301
|
+
case "transfer":
|
|
1302
|
+
if (!positionalArgs[1] || !positionalArgs[2]) {
|
|
1303
|
+
throw new Error("Source and target queue names are required");
|
|
1304
|
+
}
|
|
1305
|
+
await cmdTransfer(dbPath, positionalArgs[1], positionalArgs[2], options);
|
|
1306
|
+
break;
|
|
1307
|
+
default:
|
|
1308
|
+
throw new Error(`Unknown command: ${command2}`);
|
|
1309
|
+
}
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1312
|
+
// src/cli.ts
|
|
1313
|
+
var args = process.argv.slice(2);
|
|
1314
|
+
var command = args[0];
|
|
1315
|
+
var KNOWN_COMMANDS = [
|
|
1316
|
+
"stats",
|
|
1317
|
+
"list",
|
|
1318
|
+
"export",
|
|
1319
|
+
"import",
|
|
1320
|
+
"purge",
|
|
1321
|
+
"retry",
|
|
1322
|
+
"pause",
|
|
1323
|
+
"resume",
|
|
1324
|
+
"queues",
|
|
1325
|
+
"transfer"
|
|
1326
|
+
];
|
|
1327
|
+
function isCliCommand(value) {
|
|
1328
|
+
return KNOWN_COMMANDS.includes(value);
|
|
1329
|
+
}
|
|
490
1330
|
async function main() {
|
|
491
1331
|
if (!command || command === "--help" || command === "-h") {
|
|
492
1332
|
printUsage();
|
|
493
1333
|
process.exit(0);
|
|
494
1334
|
}
|
|
1335
|
+
if (!isCliCommand(command)) {
|
|
1336
|
+
console.error(`Unknown command: ${command}`);
|
|
1337
|
+
printUsage();
|
|
1338
|
+
process.exit(1);
|
|
1339
|
+
}
|
|
495
1340
|
const positionalArgs = getPositionalArgs(args.slice(1));
|
|
496
1341
|
const options = parseOptions(args);
|
|
497
1342
|
const dbPath = positionalArgs[0];
|
|
@@ -501,53 +1346,7 @@ async function main() {
|
|
|
501
1346
|
process.exit(1);
|
|
502
1347
|
}
|
|
503
1348
|
try {
|
|
504
|
-
|
|
505
|
-
case "stats":
|
|
506
|
-
await cmdStats(dbPath);
|
|
507
|
-
break;
|
|
508
|
-
case "list":
|
|
509
|
-
await cmdList(dbPath, options);
|
|
510
|
-
break;
|
|
511
|
-
case "export":
|
|
512
|
-
await cmdExport(dbPath, positionalArgs[1], options);
|
|
513
|
-
break;
|
|
514
|
-
case "import":
|
|
515
|
-
if (!positionalArgs[1]) {
|
|
516
|
-
console.error("Error: Input CSV file is required");
|
|
517
|
-
process.exit(1);
|
|
518
|
-
}
|
|
519
|
-
await cmdImport(dbPath, positionalArgs[1]);
|
|
520
|
-
break;
|
|
521
|
-
case "purge":
|
|
522
|
-
await cmdPurge(dbPath, options);
|
|
523
|
-
break;
|
|
524
|
-
case "retry":
|
|
525
|
-
await cmdRetry(dbPath, options);
|
|
526
|
-
break;
|
|
527
|
-
case "pause":
|
|
528
|
-
if (!positionalArgs[1]) {
|
|
529
|
-
console.error("Error: Queue name is required");
|
|
530
|
-
console.error("Example: workmatic pause ./jobs.db emails");
|
|
531
|
-
process.exit(1);
|
|
532
|
-
}
|
|
533
|
-
await cmdPause(dbPath, positionalArgs[1]);
|
|
534
|
-
break;
|
|
535
|
-
case "resume":
|
|
536
|
-
if (!positionalArgs[1]) {
|
|
537
|
-
console.error("Error: Queue name is required");
|
|
538
|
-
console.error("Example: workmatic resume ./jobs.db emails");
|
|
539
|
-
process.exit(1);
|
|
540
|
-
}
|
|
541
|
-
await cmdResume(dbPath, positionalArgs[1]);
|
|
542
|
-
break;
|
|
543
|
-
case "queues":
|
|
544
|
-
await cmdQueues(dbPath);
|
|
545
|
-
break;
|
|
546
|
-
default:
|
|
547
|
-
console.error(`Unknown command: ${command}`);
|
|
548
|
-
printUsage();
|
|
549
|
-
process.exit(1);
|
|
550
|
-
}
|
|
1349
|
+
await runCommand(command, dbPath, positionalArgs, options);
|
|
551
1350
|
} catch (error) {
|
|
552
1351
|
console.error("Error:", error instanceof Error ? error.message : error);
|
|
553
1352
|
process.exit(1);
|