@c4t4/heyamigo 0.9.7 → 0.9.8

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.
@@ -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,6 +1,6 @@
1
1
  {
2
2
  "name": "@c4t4/heyamigo",
3
- "version": "0.9.7",
3
+ "version": "0.9.8",
4
4
  "description": "WhatsApp AI bot powered by Claude with long-term memory, browser control, and role-based access",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",