@c4t4/heyamigo 0.9.7 → 0.9.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/setup.js +4 -1
- package/dist/gateway/commands.js +6 -0
- package/dist/queue/observability.js +185 -0
- package/package.json +1 -1
- package/dist/queue/persistence.js +0 -68
- package/dist/queue/queue.js +0 -49
package/dist/cli/setup.js
CHANGED
|
@@ -463,7 +463,10 @@ export async function runSetup() {
|
|
|
463
463
|
}
|
|
464
464
|
}
|
|
465
465
|
// ── Storage ──────────────────────────────────────────────────
|
|
466
|
-
|
|
466
|
+
// storage/queue (the old fastq persistence dir) removed in Phase 4
|
|
467
|
+
// — inbound queue lives in SQLite now. storage/backups holds
|
|
468
|
+
// pre-migration snapshots; created on demand by the migration runner.
|
|
469
|
+
run('mkdir -p storage/auth storage/messages storage/prompts storage/media storage/outbox');
|
|
467
470
|
run('mkdir -p storage/memory/buckets storage/memory/persons storage/memory/chats');
|
|
468
471
|
p.log.success('Storage directories ready');
|
|
469
472
|
// ── Import existing knowledge ────────────────────────────────
|
package/dist/gateway/commands.js
CHANGED
|
@@ -59,5 +59,11 @@ export async function tryCommand(ctx) {
|
|
|
59
59
|
}).catch(() => undefined);
|
|
60
60
|
return true;
|
|
61
61
|
}
|
|
62
|
+
if (cmd === 'queues') {
|
|
63
|
+
const { takeQueuesSnapshot, formatQueuesSnapshot } = await import('../queue/observability.js');
|
|
64
|
+
const snap = takeQueuesSnapshot();
|
|
65
|
+
await sendText(ctx.sock, ctx.jid, formatQueuesSnapshot(snap), ctx.quoted);
|
|
66
|
+
return true;
|
|
67
|
+
}
|
|
62
68
|
return false;
|
|
63
69
|
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
// Read-only queries that summarize queue + worker state. Used by the
|
|
2
|
+
// /queues chat command and any future HTTP dashboard.
|
|
3
|
+
//
|
|
4
|
+
// All queries are explicit raw SQL via the singleton DB handle so the
|
|
5
|
+
// resulting text matches what an operator would see if they opened
|
|
6
|
+
// the DB with sqlite3 manually.
|
|
7
|
+
import { getRawDb } from '../db/index.js';
|
|
8
|
+
const QUEUE_TABLES = ['inbound', 'outbound'];
|
|
9
|
+
const STUCK_TTL_BY_QUEUE = {
|
|
10
|
+
inbound: 360,
|
|
11
|
+
outbound: 60,
|
|
12
|
+
};
|
|
13
|
+
export function takeQueuesSnapshot() {
|
|
14
|
+
const db = getRawDb();
|
|
15
|
+
const now = Math.floor(Date.now() / 1000);
|
|
16
|
+
// Depths
|
|
17
|
+
const depths = [];
|
|
18
|
+
for (const q of QUEUE_TABLES) {
|
|
19
|
+
const rows = db
|
|
20
|
+
.prepare(`SELECT status, count(*) AS n FROM ${q} GROUP BY status`)
|
|
21
|
+
.all();
|
|
22
|
+
const counts = {
|
|
23
|
+
pending: 0, claimed: 0, failed: 0, dlq: 0,
|
|
24
|
+
};
|
|
25
|
+
for (const r of rows)
|
|
26
|
+
counts[r.status] = r.n;
|
|
27
|
+
depths.push({
|
|
28
|
+
queue: q,
|
|
29
|
+
pending: counts.pending ?? 0,
|
|
30
|
+
claimed: counts.claimed ?? 0,
|
|
31
|
+
failed: counts.failed ?? 0,
|
|
32
|
+
dlq: counts.dlq ?? 0,
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
// Stuck claims. Outbound transitions through status='sending' (the
|
|
36
|
+
// adapter call is in flight); inbound uses 'claimed'. Match either
|
|
37
|
+
// so we catch stuck rows in any queue.
|
|
38
|
+
const stuckClaims = [];
|
|
39
|
+
for (const q of QUEUE_TABLES) {
|
|
40
|
+
const ttl = STUCK_TTL_BY_QUEUE[q] ?? 60;
|
|
41
|
+
const rows = db
|
|
42
|
+
.prepare(`SELECT id, claimed_by, claimed_at
|
|
43
|
+
FROM ${q}
|
|
44
|
+
WHERE status IN ('claimed','sending') AND claimed_at < ?`)
|
|
45
|
+
.all(now - ttl);
|
|
46
|
+
for (const r of rows) {
|
|
47
|
+
stuckClaims.push({
|
|
48
|
+
queue: q,
|
|
49
|
+
id: r.id,
|
|
50
|
+
claimedBy: r.claimed_by,
|
|
51
|
+
claimedFor: now - r.claimed_at,
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
// Workers
|
|
56
|
+
const wrows = db
|
|
57
|
+
.prepare(`SELECT id, kind, status, current_job, last_seen
|
|
58
|
+
FROM workers ORDER BY kind, id`)
|
|
59
|
+
.all();
|
|
60
|
+
const workers = wrows.map((r) => ({
|
|
61
|
+
id: r.id,
|
|
62
|
+
kind: r.kind,
|
|
63
|
+
status: r.status,
|
|
64
|
+
currentJob: r.current_job,
|
|
65
|
+
ageSeconds: now - r.last_seen,
|
|
66
|
+
}));
|
|
67
|
+
// Recent failures (top 5 per queue by updated_at desc)
|
|
68
|
+
const recentFailures = [];
|
|
69
|
+
for (const q of QUEUE_TABLES) {
|
|
70
|
+
const rows = db
|
|
71
|
+
.prepare(`SELECT id, attempts, last_error, updated_at
|
|
72
|
+
FROM ${q}
|
|
73
|
+
WHERE attempts > 0 AND (status='pending' OR status='failed' OR status='dlq')
|
|
74
|
+
ORDER BY updated_at DESC LIMIT 5`)
|
|
75
|
+
.all();
|
|
76
|
+
for (const r of rows) {
|
|
77
|
+
recentFailures.push({
|
|
78
|
+
queue: q,
|
|
79
|
+
id: r.id,
|
|
80
|
+
attempts: r.attempts,
|
|
81
|
+
lastError: r.last_error,
|
|
82
|
+
ageSeconds: now - r.updated_at,
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
// Upcoming crons (next 5 due)
|
|
87
|
+
const cronRows = db
|
|
88
|
+
.prepare(`SELECT name, enqueue_into, next_run_at
|
|
89
|
+
FROM crons WHERE enabled = 1
|
|
90
|
+
ORDER BY next_run_at LIMIT 5`)
|
|
91
|
+
.all();
|
|
92
|
+
const upcomingCrons = cronRows.map((r) => ({
|
|
93
|
+
name: r.name,
|
|
94
|
+
enqueueInto: r.enqueue_into,
|
|
95
|
+
nextRunIn: r.next_run_at - now,
|
|
96
|
+
}));
|
|
97
|
+
return {
|
|
98
|
+
takenAt: now,
|
|
99
|
+
depths,
|
|
100
|
+
stuckClaims,
|
|
101
|
+
workers,
|
|
102
|
+
recentFailures,
|
|
103
|
+
upcomingCrons,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
// Format a snapshot as a single chat-friendly text block. Compact —
|
|
107
|
+
// fits comfortably in a WhatsApp message.
|
|
108
|
+
export function formatQueuesSnapshot(snap) {
|
|
109
|
+
const lines = [];
|
|
110
|
+
lines.push('*queues*');
|
|
111
|
+
// Depths
|
|
112
|
+
for (const d of snap.depths) {
|
|
113
|
+
const parts = [d.queue];
|
|
114
|
+
parts.push(`${d.pending} pending`);
|
|
115
|
+
if (d.claimed > 0)
|
|
116
|
+
parts.push(`${d.claimed} in-flight`);
|
|
117
|
+
if (d.failed > 0)
|
|
118
|
+
parts.push(`⚠ ${d.failed} failed`);
|
|
119
|
+
if (d.dlq > 0)
|
|
120
|
+
parts.push(`⚠ ${d.dlq} dlq`);
|
|
121
|
+
lines.push(' ' + parts.join(' · '));
|
|
122
|
+
}
|
|
123
|
+
// Workers
|
|
124
|
+
lines.push('*workers*');
|
|
125
|
+
const byKind = {};
|
|
126
|
+
for (const w of snap.workers) {
|
|
127
|
+
if (!byKind[w.kind])
|
|
128
|
+
byKind[w.kind] = [];
|
|
129
|
+
byKind[w.kind].push(w);
|
|
130
|
+
}
|
|
131
|
+
for (const kind of Object.keys(byKind).sort()) {
|
|
132
|
+
const ws = byKind[kind];
|
|
133
|
+
const busy = ws.filter((w) => w.status === 'busy').length;
|
|
134
|
+
const idle = ws.filter((w) => w.status === 'idle').length;
|
|
135
|
+
const draining = ws.filter((w) => w.status === 'draining').length;
|
|
136
|
+
const dead = ws.filter((w) => w.status === 'dead').length;
|
|
137
|
+
const stale = ws.filter((w) => w.ageSeconds > 30 && w.status !== 'dead').length;
|
|
138
|
+
const summary = [];
|
|
139
|
+
if (busy)
|
|
140
|
+
summary.push(`${busy} busy`);
|
|
141
|
+
if (idle)
|
|
142
|
+
summary.push(`${idle} idle`);
|
|
143
|
+
if (draining)
|
|
144
|
+
summary.push(`${draining} draining`);
|
|
145
|
+
if (dead)
|
|
146
|
+
summary.push(`⚠ ${dead} dead`);
|
|
147
|
+
if (stale)
|
|
148
|
+
summary.push(`⚠ ${stale} stale`);
|
|
149
|
+
lines.push(` ${kind}: ${summary.length ? summary.join(' · ') : 'none'}`);
|
|
150
|
+
}
|
|
151
|
+
// Stuck claims
|
|
152
|
+
if (snap.stuckClaims.length > 0) {
|
|
153
|
+
lines.push('*stuck*');
|
|
154
|
+
for (const s of snap.stuckClaims) {
|
|
155
|
+
lines.push(` ${s.queue}:${s.id} by ${s.claimedBy} for ${humanDur(s.claimedFor)}`);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
// Recent failures
|
|
159
|
+
if (snap.recentFailures.length > 0) {
|
|
160
|
+
lines.push('*failures*');
|
|
161
|
+
for (const f of snap.recentFailures) {
|
|
162
|
+
const err = (f.lastError ?? '').slice(0, 60);
|
|
163
|
+
lines.push(` ${f.queue}:${f.id} ×${f.attempts} (${humanDur(f.ageSeconds)} ago): ${err}`);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
// Upcoming crons
|
|
167
|
+
if (snap.upcomingCrons.length > 0) {
|
|
168
|
+
lines.push('*crons*');
|
|
169
|
+
for (const c of snap.upcomingCrons) {
|
|
170
|
+
const when = c.nextRunIn <= 0 ? 'due now' : `in ${humanDur(c.nextRunIn)}`;
|
|
171
|
+
lines.push(` ${c.name} → ${c.enqueueInto} (${when})`);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
return lines.join('\n');
|
|
175
|
+
}
|
|
176
|
+
function humanDur(seconds) {
|
|
177
|
+
const s = Math.abs(seconds);
|
|
178
|
+
if (s < 60)
|
|
179
|
+
return `${s}s`;
|
|
180
|
+
if (s < 3600)
|
|
181
|
+
return `${Math.round(s / 60)}m`;
|
|
182
|
+
if (s < 86400)
|
|
183
|
+
return `${Math.round(s / 3600)}h`;
|
|
184
|
+
return `${Math.round(s / 86400)}d`;
|
|
185
|
+
}
|
package/package.json
CHANGED
|
@@ -1,68 +0,0 @@
|
|
|
1
|
-
import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync, } from 'fs';
|
|
2
|
-
import { resolve } from 'path';
|
|
3
|
-
import { logger } from '../logger.js';
|
|
4
|
-
const QUEUE_DIR = resolve(process.cwd(), 'storage/queue');
|
|
5
|
-
const PENDING_FILE = resolve(QUEUE_DIR, 'pending.jsonl');
|
|
6
|
-
function ensureDir() {
|
|
7
|
-
mkdirSync(QUEUE_DIR, { recursive: true });
|
|
8
|
-
}
|
|
9
|
-
export function persistJob(job) {
|
|
10
|
-
ensureDir();
|
|
11
|
-
const line = JSON.stringify({ ...job, enqueuedAt: Date.now() }) + '\n';
|
|
12
|
-
writeFileSync(PENDING_FILE, line, { flag: 'a', encoding: 'utf-8' });
|
|
13
|
-
}
|
|
14
|
-
export function removeJob(job) {
|
|
15
|
-
if (!existsSync(PENDING_FILE))
|
|
16
|
-
return;
|
|
17
|
-
try {
|
|
18
|
-
const lines = readFileSync(PENDING_FILE, 'utf-8')
|
|
19
|
-
.split('\n')
|
|
20
|
-
.filter(Boolean);
|
|
21
|
-
const remaining = lines.filter((line) => {
|
|
22
|
-
try {
|
|
23
|
-
const parsed = JSON.parse(line);
|
|
24
|
-
return !(parsed.jid === job.jid && parsed.text === job.text);
|
|
25
|
-
}
|
|
26
|
-
catch {
|
|
27
|
-
return false;
|
|
28
|
-
}
|
|
29
|
-
});
|
|
30
|
-
if (remaining.length === 0) {
|
|
31
|
-
unlinkSync(PENDING_FILE);
|
|
32
|
-
}
|
|
33
|
-
else {
|
|
34
|
-
writeFileSync(PENDING_FILE, remaining.join('\n') + '\n', 'utf-8');
|
|
35
|
-
}
|
|
36
|
-
}
|
|
37
|
-
catch {
|
|
38
|
-
// best-effort cleanup
|
|
39
|
-
}
|
|
40
|
-
}
|
|
41
|
-
export function loadPendingJobs() {
|
|
42
|
-
if (!existsSync(PENDING_FILE))
|
|
43
|
-
return [];
|
|
44
|
-
try {
|
|
45
|
-
const lines = readFileSync(PENDING_FILE, 'utf-8')
|
|
46
|
-
.split('\n')
|
|
47
|
-
.filter(Boolean);
|
|
48
|
-
const jobs = [];
|
|
49
|
-
for (const line of lines) {
|
|
50
|
-
try {
|
|
51
|
-
const parsed = JSON.parse(line);
|
|
52
|
-
jobs.push(parsed);
|
|
53
|
-
}
|
|
54
|
-
catch {
|
|
55
|
-
logger.warn({ line: line.slice(0, 100) }, 'skipping malformed pending job');
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
|
-
logger.info({ count: jobs.length }, 'loaded pending jobs from disk');
|
|
59
|
-
return jobs;
|
|
60
|
-
}
|
|
61
|
-
catch {
|
|
62
|
-
return [];
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
export function clearPending() {
|
|
66
|
-
if (existsSync(PENDING_FILE))
|
|
67
|
-
unlinkSync(PENDING_FILE);
|
|
68
|
-
}
|
package/dist/queue/queue.js
DELETED
|
@@ -1,49 +0,0 @@
|
|
|
1
|
-
import fastq from 'fastq';
|
|
2
|
-
import { logger } from '../logger.js';
|
|
3
|
-
import { loadPendingJobs, persistJob, removeJob } from './persistence.js';
|
|
4
|
-
import { processJob } from './worker.js';
|
|
5
|
-
const queues = new Map();
|
|
6
|
-
function getQueue(jid) {
|
|
7
|
-
let q = queues.get(jid);
|
|
8
|
-
if (!q) {
|
|
9
|
-
q = fastq.promise(async (job) => {
|
|
10
|
-
try {
|
|
11
|
-
const result = await processJob(job);
|
|
12
|
-
removeJob(job);
|
|
13
|
-
return result;
|
|
14
|
-
}
|
|
15
|
-
catch (err) {
|
|
16
|
-
removeJob(job);
|
|
17
|
-
throw err;
|
|
18
|
-
}
|
|
19
|
-
}, 1);
|
|
20
|
-
queues.set(jid, q);
|
|
21
|
-
}
|
|
22
|
-
return q;
|
|
23
|
-
}
|
|
24
|
-
export async function enqueue(job) {
|
|
25
|
-
persistJob(job);
|
|
26
|
-
return getQueue(job.jid).push(job);
|
|
27
|
-
}
|
|
28
|
-
/**
|
|
29
|
-
* On boot, replay any jobs that were persisted but never completed
|
|
30
|
-
* (process crashed mid-queue). Returns a promise that resolves when
|
|
31
|
-
* all replayed jobs finish or fail. Caller provides a handler for
|
|
32
|
-
* results since the original WAMessage context is gone.
|
|
33
|
-
*/
|
|
34
|
-
export async function replayPending(onResult) {
|
|
35
|
-
const pending = loadPendingJobs();
|
|
36
|
-
if (!pending.length)
|
|
37
|
-
return;
|
|
38
|
-
logger.info({ count: pending.length }, 'replaying pending jobs from last session');
|
|
39
|
-
const promises = pending.map(async (job) => {
|
|
40
|
-
try {
|
|
41
|
-
const result = await getQueue(job.jid).push(job);
|
|
42
|
-
await onResult(job, result);
|
|
43
|
-
}
|
|
44
|
-
catch (err) {
|
|
45
|
-
logger.error({ err, jid: job.jid }, 'replayed job failed');
|
|
46
|
-
}
|
|
47
|
-
});
|
|
48
|
-
await Promise.allSettled(promises);
|
|
49
|
-
}
|