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