@c4t4/heyamigo 0.9.5 → 0.9.7
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/boot.js +67 -0
- package/dist/cli/start.js +3 -21
- package/dist/config.js +8 -0
- package/dist/db/schema.js +57 -0
- package/dist/gateway/incoming.js +26 -42
- package/dist/index.js +3 -55
- package/dist/queue/chat-worker.js +183 -0
- package/dist/queue/inbound.js +191 -0
- package/dist/queue/orchestrator.js +8 -3
- package/migrations/0003_phase4_inbound.sql +28 -0
- package/migrations/0004_phase4_inbound_payload.sql +1 -0
- package/migrations/meta/0003_snapshot.json +658 -0
- package/migrations/meta/0004_snapshot.json +665 -0
- package/migrations/meta/_journal.json +14 -0
- package/package.json +1 -1
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
// Inbound queue helpers. Gateway (gateway/incoming.ts) calls
|
|
2
|
+
// enqueueInbound; chat workers (queue/chat-worker.ts) drain via
|
|
3
|
+
// claimNextInbound. Per-address serialization preserves reply order
|
|
4
|
+
// within a chat while different chats run in parallel.
|
|
5
|
+
//
|
|
6
|
+
// Same primitives as outbound (claim/done/retry/dlq) with the added
|
|
7
|
+
// `address NOT IN (claimed)` filter in the claim query.
|
|
8
|
+
import { and, asc, eq, isNull, lte, notInArray, or, sql } from 'drizzle-orm';
|
|
9
|
+
import { getDb } from '../db/index.js';
|
|
10
|
+
import { inbound } from '../db/schema.js';
|
|
11
|
+
// Idempotent on external_msg_id when set. Same channel message
|
|
12
|
+
// arriving twice (Baileys replay, network retransmit) returns the
|
|
13
|
+
// existing row instead of duplicating.
|
|
14
|
+
export function enqueueInbound(input) {
|
|
15
|
+
const db = getDb();
|
|
16
|
+
const now = Math.floor(Date.now() / 1000);
|
|
17
|
+
if (input.externalMsgId) {
|
|
18
|
+
const found = db
|
|
19
|
+
.select()
|
|
20
|
+
.from(inbound)
|
|
21
|
+
.where(eq(inbound.externalMsgId, input.externalMsgId))
|
|
22
|
+
.get();
|
|
23
|
+
if (found)
|
|
24
|
+
return { inserted: false, row: found };
|
|
25
|
+
}
|
|
26
|
+
const row = db
|
|
27
|
+
.insert(inbound)
|
|
28
|
+
.values({
|
|
29
|
+
address: input.address,
|
|
30
|
+
actorAddress: input.actorAddress ?? null,
|
|
31
|
+
personId: input.personId ?? null,
|
|
32
|
+
actorPersonId: input.actorPersonId ?? null,
|
|
33
|
+
externalMsgId: input.externalMsgId ?? null,
|
|
34
|
+
text: input.text,
|
|
35
|
+
mediaPath: input.mediaPath ?? null,
|
|
36
|
+
mediaMime: input.mediaMime ?? null,
|
|
37
|
+
mediaBytes: input.mediaBytes ?? null,
|
|
38
|
+
pushName: input.pushName ?? null,
|
|
39
|
+
triggerReason: input.triggerReason ?? null,
|
|
40
|
+
payload: input.payload === undefined ? null : JSON.stringify(input.payload),
|
|
41
|
+
status: 'pending',
|
|
42
|
+
attempts: 0,
|
|
43
|
+
nextAttemptAt: null,
|
|
44
|
+
lastError: null,
|
|
45
|
+
claimedBy: null,
|
|
46
|
+
claimedAt: null,
|
|
47
|
+
receivedAt: input.receivedAt ?? now,
|
|
48
|
+
createdAt: now,
|
|
49
|
+
updatedAt: now,
|
|
50
|
+
})
|
|
51
|
+
.returning()
|
|
52
|
+
.get();
|
|
53
|
+
return { inserted: true, row };
|
|
54
|
+
}
|
|
55
|
+
// Atomic claim with per-address serialization. Skips any pending row
|
|
56
|
+
// whose address already has another row in `claimed` state →
|
|
57
|
+
// preserves reply order per chat while letting different chats run
|
|
58
|
+
// in parallel.
|
|
59
|
+
export function claimNextInbound(workerId) {
|
|
60
|
+
const db = getDb();
|
|
61
|
+
const now = Math.floor(Date.now() / 1000);
|
|
62
|
+
return db.transaction((tx) => {
|
|
63
|
+
// Subquery: addresses currently claimed (= one in-flight per chat).
|
|
64
|
+
const busyAddrs = tx
|
|
65
|
+
.select({ address: inbound.address })
|
|
66
|
+
.from(inbound)
|
|
67
|
+
.where(eq(inbound.status, 'claimed'))
|
|
68
|
+
.all()
|
|
69
|
+
.map((r) => r.address);
|
|
70
|
+
const conds = [
|
|
71
|
+
eq(inbound.status, 'pending'),
|
|
72
|
+
or(isNull(inbound.nextAttemptAt), lte(inbound.nextAttemptAt, now)),
|
|
73
|
+
];
|
|
74
|
+
if (busyAddrs.length > 0) {
|
|
75
|
+
conds.push(notInArray(inbound.address, busyAddrs));
|
|
76
|
+
}
|
|
77
|
+
const target = tx
|
|
78
|
+
.select({ id: inbound.id })
|
|
79
|
+
.from(inbound)
|
|
80
|
+
.where(and(...conds))
|
|
81
|
+
.orderBy(asc(inbound.id))
|
|
82
|
+
.limit(1)
|
|
83
|
+
.get();
|
|
84
|
+
if (!target)
|
|
85
|
+
return null;
|
|
86
|
+
const claimed = tx
|
|
87
|
+
.update(inbound)
|
|
88
|
+
.set({
|
|
89
|
+
status: 'claimed',
|
|
90
|
+
claimedBy: workerId,
|
|
91
|
+
claimedAt: now,
|
|
92
|
+
updatedAt: now,
|
|
93
|
+
})
|
|
94
|
+
.where(and(eq(inbound.id, target.id), eq(inbound.status, 'pending')))
|
|
95
|
+
.returning()
|
|
96
|
+
.get();
|
|
97
|
+
return claimed ?? null;
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
export function markInboundDone(id, workerId) {
|
|
101
|
+
const db = getDb();
|
|
102
|
+
const now = Math.floor(Date.now() / 1000);
|
|
103
|
+
const result = db
|
|
104
|
+
.update(inbound)
|
|
105
|
+
.set({ status: 'done', updatedAt: now })
|
|
106
|
+
.where(and(eq(inbound.id, id), eq(inbound.status, 'claimed'), eq(inbound.claimedBy, workerId)))
|
|
107
|
+
.returning({ id: inbound.id })
|
|
108
|
+
.all();
|
|
109
|
+
return result.length > 0;
|
|
110
|
+
}
|
|
111
|
+
// Backoff: 5s, 30s, 2min, 5min, give up. Bigger gaps than outbound
|
|
112
|
+
// because chat replies are expensive (AI call); a faster retry loop
|
|
113
|
+
// would just burn tokens on transient errors.
|
|
114
|
+
const BACKOFF_SECONDS = [5, 30, 120, 300];
|
|
115
|
+
const MAX_ATTEMPTS = BACKOFF_SECONDS.length;
|
|
116
|
+
export function markInboundRetryOrDlq(id, workerId, errorMessage) {
|
|
117
|
+
const db = getDb();
|
|
118
|
+
return db.transaction((tx) => {
|
|
119
|
+
const row = tx.select().from(inbound).where(eq(inbound.id, id)).get();
|
|
120
|
+
if (!row || row.status !== 'claimed' || row.claimedBy !== workerId) {
|
|
121
|
+
return { retried: false, deadLettered: false };
|
|
122
|
+
}
|
|
123
|
+
const now = Math.floor(Date.now() / 1000);
|
|
124
|
+
const nextAttempts = row.attempts + 1;
|
|
125
|
+
if (nextAttempts > MAX_ATTEMPTS) {
|
|
126
|
+
tx.update(inbound)
|
|
127
|
+
.set({
|
|
128
|
+
status: 'dlq',
|
|
129
|
+
attempts: nextAttempts,
|
|
130
|
+
lastError: errorMessage,
|
|
131
|
+
claimedBy: null,
|
|
132
|
+
claimedAt: null,
|
|
133
|
+
updatedAt: now,
|
|
134
|
+
})
|
|
135
|
+
.where(eq(inbound.id, id))
|
|
136
|
+
.run();
|
|
137
|
+
return { retried: false, deadLettered: true };
|
|
138
|
+
}
|
|
139
|
+
const backoff = BACKOFF_SECONDS[Math.min(row.attempts, BACKOFF_SECONDS.length - 1)];
|
|
140
|
+
tx.update(inbound)
|
|
141
|
+
.set({
|
|
142
|
+
status: 'pending',
|
|
143
|
+
attempts: nextAttempts,
|
|
144
|
+
nextAttemptAt: now + backoff,
|
|
145
|
+
lastError: errorMessage,
|
|
146
|
+
claimedBy: null,
|
|
147
|
+
claimedAt: null,
|
|
148
|
+
updatedAt: now,
|
|
149
|
+
})
|
|
150
|
+
.where(eq(inbound.id, id))
|
|
151
|
+
.run();
|
|
152
|
+
return { retried: true, deadLettered: false };
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
export function markInboundFailed(id, workerId, errorMessage) {
|
|
156
|
+
const db = getDb();
|
|
157
|
+
const now = Math.floor(Date.now() / 1000);
|
|
158
|
+
const result = db
|
|
159
|
+
.update(inbound)
|
|
160
|
+
.set({
|
|
161
|
+
status: 'failed',
|
|
162
|
+
lastError: errorMessage,
|
|
163
|
+
claimedBy: null,
|
|
164
|
+
claimedAt: null,
|
|
165
|
+
updatedAt: now,
|
|
166
|
+
})
|
|
167
|
+
.where(and(eq(inbound.id, id), eq(inbound.status, 'claimed'), eq(inbound.claimedBy, workerId)))
|
|
168
|
+
.returning({ id: inbound.id })
|
|
169
|
+
.all();
|
|
170
|
+
return result.length > 0;
|
|
171
|
+
}
|
|
172
|
+
// Orchestrator helper. Chat workers run longer than sender workers
|
|
173
|
+
// (AI calls + memory writes), so the TTL is more generous. 300s
|
|
174
|
+
// matches the typical chat-track timeout (5min).
|
|
175
|
+
const CLAIM_TTL_SECONDS = 360;
|
|
176
|
+
export function reclaimStuckInbound() {
|
|
177
|
+
const db = getDb();
|
|
178
|
+
const cutoff = Math.floor(Date.now() / 1000) - CLAIM_TTL_SECONDS;
|
|
179
|
+
const result = db
|
|
180
|
+
.update(inbound)
|
|
181
|
+
.set({
|
|
182
|
+
status: 'pending',
|
|
183
|
+
claimedBy: null,
|
|
184
|
+
claimedAt: null,
|
|
185
|
+
updatedAt: sql `${inbound.updatedAt}`,
|
|
186
|
+
})
|
|
187
|
+
.where(and(eq(inbound.status, 'claimed'), lte(inbound.claimedAt, cutoff)))
|
|
188
|
+
.returning({ id: inbound.id })
|
|
189
|
+
.all();
|
|
190
|
+
return result.length;
|
|
191
|
+
}
|
|
@@ -16,6 +16,7 @@ import { and, eq, lt, ne } from 'drizzle-orm';
|
|
|
16
16
|
import { getDb } from '../db/index.js';
|
|
17
17
|
import { workers } from '../db/schema.js';
|
|
18
18
|
import { logger } from '../logger.js';
|
|
19
|
+
import { reclaimStuckInbound } from './inbound.js';
|
|
19
20
|
import { reclaimStuckOutbound } from './outbound.js';
|
|
20
21
|
import { clearControl, readControl, requestControl } from './control.js';
|
|
21
22
|
import { listDueCrons, markCronFired } from './crons.js';
|
|
@@ -98,9 +99,13 @@ async function tick(id) {
|
|
|
98
99
|
.run();
|
|
99
100
|
}
|
|
100
101
|
// Cross-queue housekeeping. More queues land in later phases.
|
|
101
|
-
const
|
|
102
|
-
if (
|
|
103
|
-
logger.info({ reclaimed }, 'reclaimed stuck outbound rows');
|
|
102
|
+
const reclaimedOutbound = reclaimStuckOutbound();
|
|
103
|
+
if (reclaimedOutbound > 0) {
|
|
104
|
+
logger.info({ reclaimed: reclaimedOutbound }, 'reclaimed stuck outbound rows');
|
|
105
|
+
}
|
|
106
|
+
const reclaimedInbound = reclaimStuckInbound();
|
|
107
|
+
if (reclaimedInbound > 0) {
|
|
108
|
+
logger.info({ reclaimed: reclaimedInbound }, 'reclaimed stuck inbound rows');
|
|
104
109
|
}
|
|
105
110
|
// Fire any due crons. Order: dispatch each in turn; if dispatch
|
|
106
111
|
// throws (it shouldn't — dispatch swallows), the cron is NOT
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
CREATE TABLE `inbound` (
|
|
2
|
+
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
|
3
|
+
`address` text NOT NULL,
|
|
4
|
+
`actor_address` text,
|
|
5
|
+
`person_id` text,
|
|
6
|
+
`actor_person_id` text,
|
|
7
|
+
`external_msg_id` text,
|
|
8
|
+
`text` text NOT NULL,
|
|
9
|
+
`media_path` text,
|
|
10
|
+
`media_mime` text,
|
|
11
|
+
`media_bytes` integer,
|
|
12
|
+
`push_name` text,
|
|
13
|
+
`trigger_reason` text,
|
|
14
|
+
`status` text NOT NULL,
|
|
15
|
+
`attempts` integer DEFAULT 0 NOT NULL,
|
|
16
|
+
`next_attempt_at` integer,
|
|
17
|
+
`last_error` text,
|
|
18
|
+
`claimed_by` text,
|
|
19
|
+
`claimed_at` integer,
|
|
20
|
+
`received_at` integer NOT NULL,
|
|
21
|
+
`created_at` integer NOT NULL,
|
|
22
|
+
`updated_at` integer NOT NULL
|
|
23
|
+
);
|
|
24
|
+
--> statement-breakpoint
|
|
25
|
+
CREATE INDEX `inbound_by_status_next` ON `inbound` (`status`,`next_attempt_at`);--> statement-breakpoint
|
|
26
|
+
CREATE INDEX `inbound_by_address` ON `inbound` (`address`);--> statement-breakpoint
|
|
27
|
+
CREATE INDEX `inbound_by_person` ON `inbound` (`person_id`,`received_at`);--> statement-breakpoint
|
|
28
|
+
CREATE UNIQUE INDEX `inbound_external_msg_id_uq` ON `inbound` (`external_msg_id`) WHERE "inbound"."external_msg_id" IS NOT NULL;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
ALTER TABLE `inbound` ADD `payload` text;
|