workmatic 1.0.5 → 1.1.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/README.md +114 -13
- package/dashboard/app.js +1 -3
- package/dashboard/index.html +0 -14
- package/dist/cli.cjs +905 -108
- package/dist/cli.cjs.map +1 -1
- package/dist/cli.js +905 -108
- package/dist/cli.js.map +1 -1
- package/dist/index.cjs +360 -66
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +118 -6
- package/dist/index.d.ts +118 -6
- package/dist/index.js +357 -66
- package/dist/index.js.map +1 -1
- package/package.json +4 -1
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
|
|
@@ -44,23 +783,21 @@ Commands:
|
|
|
44
783
|
export <db> [output.csv] Export jobs to CSV (default: stdout)
|
|
45
784
|
import <db> <input.csv> Import jobs from CSV
|
|
46
785
|
purge <db> --status=<status> Delete jobs by status
|
|
47
|
-
retry <db> [--status=dead] Retry dead
|
|
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|
|
|
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=failed > failed-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,28 +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
|
-
const statusOrder = ["ready", "running", "done", "
|
|
136
|
-
const statusEmoji = {
|
|
137
|
-
ready: "\u23F3",
|
|
138
|
-
running: "\u25B6\uFE0F",
|
|
139
|
-
done: "\u2705",
|
|
140
|
-
failed: "\u26A0\uFE0F",
|
|
141
|
-
dead: "\u{1F480}"
|
|
142
|
-
};
|
|
876
|
+
const statusOrder = ["ready", "running", "done", "dead"];
|
|
143
877
|
const statusTable = new import_cli_table3.default({
|
|
144
878
|
head: ["", "Status", "Count"],
|
|
145
879
|
style: { head: ["cyan"], border: ["gray"] },
|
|
@@ -148,7 +882,7 @@ async function cmdStats(dbPath) {
|
|
|
148
882
|
for (const status of statusOrder) {
|
|
149
883
|
const stat = stats.find((s) => s.status === status);
|
|
150
884
|
const count = stat?.count ?? 0;
|
|
151
|
-
const emoji =
|
|
885
|
+
const emoji = jobStatusEmoji(status);
|
|
152
886
|
statusTable.push([emoji, status, count.toLocaleString()]);
|
|
153
887
|
}
|
|
154
888
|
statusTable.push([{ colSpan: 2, content: "Total", hAlign: "right" }, total.toLocaleString()]);
|
|
@@ -171,7 +905,7 @@ async function cmdStats(dbPath) {
|
|
|
171
905
|
}
|
|
172
906
|
}
|
|
173
907
|
async function cmdList(dbPath, options) {
|
|
174
|
-
const db = new
|
|
908
|
+
const db = new import_better_sqlite32.default(dbPath, { readonly: true });
|
|
175
909
|
try {
|
|
176
910
|
let query = "SELECT public_id, queue, status, priority, attempts, max_attempts, created_at, last_error FROM workmatic_jobs WHERE 1=1";
|
|
177
911
|
const params = [];
|
|
@@ -190,13 +924,6 @@ async function cmdList(dbPath, options) {
|
|
|
190
924
|
console.log("No jobs found.");
|
|
191
925
|
return;
|
|
192
926
|
}
|
|
193
|
-
const statusEmoji = {
|
|
194
|
-
ready: "\u23F3",
|
|
195
|
-
running: "\u25B6\uFE0F",
|
|
196
|
-
done: "\u2705",
|
|
197
|
-
failed: "\u26A0\uFE0F",
|
|
198
|
-
dead: "\u{1F480}"
|
|
199
|
-
};
|
|
200
927
|
const table = new import_cli_table3.default({
|
|
201
928
|
head: ["ID", "Queue", "Status", "Pri", "Attempts", "Created"],
|
|
202
929
|
style: { head: ["cyan"], border: ["gray"] },
|
|
@@ -205,7 +932,7 @@ async function cmdList(dbPath, options) {
|
|
|
205
932
|
wordWrap: true
|
|
206
933
|
});
|
|
207
934
|
for (const job of jobs) {
|
|
208
|
-
const emoji =
|
|
935
|
+
const emoji = jobStatusEmoji(job.status);
|
|
209
936
|
table.push([
|
|
210
937
|
job.public_id.slice(0, 21),
|
|
211
938
|
job.queue.slice(0, 14),
|
|
@@ -225,7 +952,7 @@ async function cmdList(dbPath, options) {
|
|
|
225
952
|
}
|
|
226
953
|
}
|
|
227
954
|
async function cmdExport(dbPath, outputPath, options) {
|
|
228
|
-
const db = new
|
|
955
|
+
const db = new import_better_sqlite32.default(dbPath, { readonly: true });
|
|
229
956
|
try {
|
|
230
957
|
let query = "SELECT * FROM workmatic_jobs WHERE 1=1";
|
|
231
958
|
const params = [];
|
|
@@ -268,7 +995,9 @@ async function cmdExport(dbPath, outputPath, options) {
|
|
|
268
995
|
}
|
|
269
996
|
}
|
|
270
997
|
if (outputPath) {
|
|
271
|
-
output
|
|
998
|
+
const stream = output;
|
|
999
|
+
stream.end();
|
|
1000
|
+
await (0, import_promises.finished)(stream);
|
|
272
1001
|
console.error(`\u2705 Exported ${jobs.length} jobs to ${outputPath}`);
|
|
273
1002
|
}
|
|
274
1003
|
} finally {
|
|
@@ -276,7 +1005,7 @@ async function cmdExport(dbPath, outputPath, options) {
|
|
|
276
1005
|
}
|
|
277
1006
|
}
|
|
278
1007
|
async function cmdImport(dbPath, inputPath) {
|
|
279
|
-
const db = new
|
|
1008
|
+
const db = new import_better_sqlite32.default(dbPath);
|
|
280
1009
|
db.exec(`
|
|
281
1010
|
CREATE TABLE IF NOT EXISTS workmatic_jobs (
|
|
282
1011
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
@@ -357,11 +1086,9 @@ async function cmdImport(dbPath, inputPath) {
|
|
|
357
1086
|
}
|
|
358
1087
|
async function cmdPurge(dbPath, options) {
|
|
359
1088
|
if (!options.status) {
|
|
360
|
-
|
|
361
|
-
console.error("Example: workmatic purge ./jobs.db --status=done");
|
|
362
|
-
process.exit(1);
|
|
1089
|
+
throw new Error("--status is required for purge command");
|
|
363
1090
|
}
|
|
364
|
-
const db = new
|
|
1091
|
+
const db = new import_better_sqlite32.default(dbPath);
|
|
365
1092
|
try {
|
|
366
1093
|
const result = db.prepare("DELETE FROM workmatic_jobs WHERE status = ?").run(options.status);
|
|
367
1094
|
console.log(`\u{1F5D1}\uFE0F Deleted ${result.changes} jobs with status '${options.status}'`);
|
|
@@ -371,10 +1098,11 @@ async function cmdPurge(dbPath, options) {
|
|
|
371
1098
|
}
|
|
372
1099
|
async function cmdRetry(dbPath, options) {
|
|
373
1100
|
const status = options.status || "dead";
|
|
374
|
-
const db = new
|
|
1101
|
+
const db = new import_better_sqlite32.default(dbPath);
|
|
375
1102
|
try {
|
|
376
|
-
const
|
|
377
|
-
const result = db.prepare(
|
|
1103
|
+
const ts = Date.now();
|
|
1104
|
+
const result = db.prepare(
|
|
1105
|
+
`
|
|
378
1106
|
UPDATE workmatic_jobs
|
|
379
1107
|
SET status = 'ready',
|
|
380
1108
|
attempts = 0,
|
|
@@ -382,14 +1110,15 @@ async function cmdRetry(dbPath, options) {
|
|
|
382
1110
|
lease_until = 0,
|
|
383
1111
|
updated_at = ?
|
|
384
1112
|
WHERE status = ?
|
|
385
|
-
`
|
|
1113
|
+
`
|
|
1114
|
+
).run(ts, ts, status);
|
|
386
1115
|
console.log(`\u{1F504} Reset ${result.changes} jobs from '${status}' to 'ready'`);
|
|
387
1116
|
} finally {
|
|
388
1117
|
db.close();
|
|
389
1118
|
}
|
|
390
1119
|
}
|
|
391
1120
|
async function cmdPause(dbPath, queueName) {
|
|
392
|
-
const db = new
|
|
1121
|
+
const db = new import_better_sqlite32.default(dbPath);
|
|
393
1122
|
try {
|
|
394
1123
|
db.exec(`
|
|
395
1124
|
CREATE TABLE IF NOT EXISTS workmatic_settings (
|
|
@@ -398,12 +1127,14 @@ async function cmdPause(dbPath, queueName) {
|
|
|
398
1127
|
updated_at INTEGER NOT NULL
|
|
399
1128
|
)
|
|
400
1129
|
`);
|
|
401
|
-
const
|
|
402
|
-
db.prepare(
|
|
1130
|
+
const ts = Date.now();
|
|
1131
|
+
db.prepare(
|
|
1132
|
+
`
|
|
403
1133
|
INSERT INTO workmatic_settings (queue, paused, updated_at)
|
|
404
1134
|
VALUES (?, 1, ?)
|
|
405
1135
|
ON CONFLICT(queue) DO UPDATE SET paused = 1, updated_at = ?
|
|
406
|
-
`
|
|
1136
|
+
`
|
|
1137
|
+
).run(queueName, ts, ts);
|
|
407
1138
|
console.log(`\u23F8\uFE0F Paused queue '${queueName}'`);
|
|
408
1139
|
console.log(` Running workers will stop claiming new jobs.`);
|
|
409
1140
|
} finally {
|
|
@@ -411,7 +1142,7 @@ async function cmdPause(dbPath, queueName) {
|
|
|
411
1142
|
}
|
|
412
1143
|
}
|
|
413
1144
|
async function cmdResume(dbPath, queueName) {
|
|
414
|
-
const db = new
|
|
1145
|
+
const db = new import_better_sqlite32.default(dbPath);
|
|
415
1146
|
try {
|
|
416
1147
|
db.exec(`
|
|
417
1148
|
CREATE TABLE IF NOT EXISTS workmatic_settings (
|
|
@@ -420,12 +1151,14 @@ async function cmdResume(dbPath, queueName) {
|
|
|
420
1151
|
updated_at INTEGER NOT NULL
|
|
421
1152
|
)
|
|
422
1153
|
`);
|
|
423
|
-
const
|
|
424
|
-
db.prepare(
|
|
1154
|
+
const ts = Date.now();
|
|
1155
|
+
db.prepare(
|
|
1156
|
+
`
|
|
425
1157
|
INSERT INTO workmatic_settings (queue, paused, updated_at)
|
|
426
1158
|
VALUES (?, 0, ?)
|
|
427
1159
|
ON CONFLICT(queue) DO UPDATE SET paused = 0, updated_at = ?
|
|
428
|
-
`
|
|
1160
|
+
`
|
|
1161
|
+
).run(queueName, ts, ts);
|
|
429
1162
|
console.log(`\u25B6\uFE0F Resumed queue '${queueName}'`);
|
|
430
1163
|
console.log(` Workers will start claiming jobs again.`);
|
|
431
1164
|
} finally {
|
|
@@ -433,14 +1166,17 @@ async function cmdResume(dbPath, queueName) {
|
|
|
433
1166
|
}
|
|
434
1167
|
}
|
|
435
1168
|
async function cmdQueues(dbPath) {
|
|
436
|
-
const db = new
|
|
1169
|
+
const db = new import_better_sqlite32.default(dbPath, { readonly: true });
|
|
437
1170
|
try {
|
|
438
|
-
const settingsExists = db.prepare(
|
|
1171
|
+
const settingsExists = db.prepare(
|
|
1172
|
+
`
|
|
439
1173
|
SELECT name FROM sqlite_master WHERE type='table' AND name='workmatic_settings'
|
|
440
|
-
`
|
|
1174
|
+
`
|
|
1175
|
+
).get();
|
|
441
1176
|
let queues;
|
|
442
1177
|
if (settingsExists) {
|
|
443
|
-
queues = db.prepare(
|
|
1178
|
+
queues = db.prepare(
|
|
1179
|
+
`
|
|
444
1180
|
SELECT
|
|
445
1181
|
j.queue,
|
|
446
1182
|
COUNT(*) as total,
|
|
@@ -450,9 +1186,11 @@ async function cmdQueues(dbPath) {
|
|
|
450
1186
|
FROM workmatic_jobs j
|
|
451
1187
|
LEFT JOIN workmatic_settings s ON j.queue = s.queue
|
|
452
1188
|
GROUP BY j.queue
|
|
453
|
-
`
|
|
1189
|
+
`
|
|
1190
|
+
).all();
|
|
454
1191
|
} else {
|
|
455
|
-
queues = db.prepare(
|
|
1192
|
+
queues = db.prepare(
|
|
1193
|
+
`
|
|
456
1194
|
SELECT
|
|
457
1195
|
queue,
|
|
458
1196
|
COUNT(*) as total,
|
|
@@ -461,7 +1199,8 @@ async function cmdQueues(dbPath) {
|
|
|
461
1199
|
0 as paused
|
|
462
1200
|
FROM workmatic_jobs
|
|
463
1201
|
GROUP BY queue
|
|
464
|
-
`
|
|
1202
|
+
`
|
|
1203
|
+
).all();
|
|
465
1204
|
}
|
|
466
1205
|
if (queues.length === 0) {
|
|
467
1206
|
console.log("No queues found.");
|
|
@@ -489,11 +1228,115 @@ async function cmdQueues(dbPath) {
|
|
|
489
1228
|
db.close();
|
|
490
1229
|
}
|
|
491
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
|
+
}
|
|
492
1330
|
async function main() {
|
|
493
1331
|
if (!command || command === "--help" || command === "-h") {
|
|
494
1332
|
printUsage();
|
|
495
1333
|
process.exit(0);
|
|
496
1334
|
}
|
|
1335
|
+
if (!isCliCommand(command)) {
|
|
1336
|
+
console.error(`Unknown command: ${command}`);
|
|
1337
|
+
printUsage();
|
|
1338
|
+
process.exit(1);
|
|
1339
|
+
}
|
|
497
1340
|
const positionalArgs = getPositionalArgs(args.slice(1));
|
|
498
1341
|
const options = parseOptions(args);
|
|
499
1342
|
const dbPath = positionalArgs[0];
|
|
@@ -503,53 +1346,7 @@ async function main() {
|
|
|
503
1346
|
process.exit(1);
|
|
504
1347
|
}
|
|
505
1348
|
try {
|
|
506
|
-
|
|
507
|
-
case "stats":
|
|
508
|
-
await cmdStats(dbPath);
|
|
509
|
-
break;
|
|
510
|
-
case "list":
|
|
511
|
-
await cmdList(dbPath, options);
|
|
512
|
-
break;
|
|
513
|
-
case "export":
|
|
514
|
-
await cmdExport(dbPath, positionalArgs[1], options);
|
|
515
|
-
break;
|
|
516
|
-
case "import":
|
|
517
|
-
if (!positionalArgs[1]) {
|
|
518
|
-
console.error("Error: Input CSV file is required");
|
|
519
|
-
process.exit(1);
|
|
520
|
-
}
|
|
521
|
-
await cmdImport(dbPath, positionalArgs[1]);
|
|
522
|
-
break;
|
|
523
|
-
case "purge":
|
|
524
|
-
await cmdPurge(dbPath, options);
|
|
525
|
-
break;
|
|
526
|
-
case "retry":
|
|
527
|
-
await cmdRetry(dbPath, options);
|
|
528
|
-
break;
|
|
529
|
-
case "pause":
|
|
530
|
-
if (!positionalArgs[1]) {
|
|
531
|
-
console.error("Error: Queue name is required");
|
|
532
|
-
console.error("Example: workmatic pause ./jobs.db emails");
|
|
533
|
-
process.exit(1);
|
|
534
|
-
}
|
|
535
|
-
await cmdPause(dbPath, positionalArgs[1]);
|
|
536
|
-
break;
|
|
537
|
-
case "resume":
|
|
538
|
-
if (!positionalArgs[1]) {
|
|
539
|
-
console.error("Error: Queue name is required");
|
|
540
|
-
console.error("Example: workmatic resume ./jobs.db emails");
|
|
541
|
-
process.exit(1);
|
|
542
|
-
}
|
|
543
|
-
await cmdResume(dbPath, positionalArgs[1]);
|
|
544
|
-
break;
|
|
545
|
-
case "queues":
|
|
546
|
-
await cmdQueues(dbPath);
|
|
547
|
-
break;
|
|
548
|
-
default:
|
|
549
|
-
console.error(`Unknown command: ${command}`);
|
|
550
|
-
printUsage();
|
|
551
|
-
process.exit(1);
|
|
552
|
-
}
|
|
1349
|
+
await runCommand(command, dbPath, positionalArgs, options);
|
|
553
1350
|
} catch (error) {
|
|
554
1351
|
console.error("Error:", error instanceof Error ? error.message : error);
|
|
555
1352
|
process.exit(1);
|