@c4t4/heyamigo 0.9.2 → 0.9.4

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/db/schema.js CHANGED
@@ -95,3 +95,36 @@ export const outbound = sqliteTable('outbound', {
95
95
  .on(t.idempotencyKey)
96
96
  .where(sql `${t.idempotencyKey} IS NOT NULL`),
97
97
  }));
98
+ // ──────────────────────────────────────────────────────────────────
99
+ // Cron table (Phase 2.2)
100
+ // ──────────────────────────────────────────────────────────────────
101
+ // Schedules. Not really a queue — a *table polled by the orchestrator*
102
+ // that fires inserts into the other queues when next_run_at <= now.
103
+ //
104
+ // recurrence:
105
+ // null → one-shot (deleted after firing)
106
+ // '@every <n><unit>' → 'every 30s', 'every 5m', 'every 3h', 'every 7d'
107
+ // '@daily HH:MM' → daily at owner-tz local time
108
+ // '@weekly DOW HH:MM' → weekly at owner-tz local time
109
+ // DOW = mon|tue|wed|thu|fri|sat|sun
110
+ // (Cron expressions intentionally NOT supported until we need them —
111
+ // own@every / @daily / @weekly covers every existing setInterval.)
112
+ export const crons = sqliteTable('crons', {
113
+ id: integer('id').primaryKey({ autoIncrement: true }),
114
+ name: text('name').notNull(), // human-readable; uniqueness enforced for non-oneshot
115
+ enqueueInto: text('enqueue_into').notNull(), // 'inbound'|'async'|'outbound'|'memory_writes'
116
+ payload: text('payload').notNull(), // JSON passed to the target queue
117
+ recurrence: text('recurrence'), // null = one-shot
118
+ nextRunAt: integer('next_run_at').notNull(),
119
+ lastRunAt: integer('last_run_at'),
120
+ enabled: integer('enabled').notNull().default(1), // SQLite bool = int
121
+ createdAt: integer('created_at').notNull(),
122
+ }, t => ({
123
+ byDue: index('crons_by_due').on(t.enabled, t.nextRunAt),
124
+ // Named recurring crons must be unique — prevents accidental
125
+ // duplicate schedules from setup wizard re-runs etc. One-shots
126
+ // (recurrence IS NULL) can have any name.
127
+ uniqName: uniqueIndex('crons_name_uq')
128
+ .on(t.name)
129
+ .where(sql `${t.recurrence} IS NOT NULL`),
130
+ }));
package/dist/index.js CHANGED
@@ -5,6 +5,7 @@ import { attachIncoming } from './gateway/incoming.js';
5
5
  import { handleReply } from './gateway/outgoing.js';
6
6
  import { logger } from './logger.js';
7
7
  import { startScheduler } from './memory/scheduler.js';
8
+ import { requestShutdown, startOrchestrator, stopOrchestrator, } from './queue/orchestrator.js';
8
9
  import { replayPending } from './queue/queue.js';
9
10
  import { startSenderWorker, stopSenderWorker } from './queue/sender-worker.js';
10
11
  import { startSocket } from './wa/socket.js';
@@ -14,6 +15,16 @@ async function main() {
14
15
  initDb();
15
16
  // Derived view: populate persons + identities from access.json.
16
17
  syncIdentitiesFromAccess();
18
+ // Orchestrator handles cross-cutting bookkeeping: control table
19
+ // signals, stuck-claim reclaim, dead-worker detection, cron polling
20
+ // (Phase 2.2+). Starts before workers so it can see them register.
21
+ startOrchestrator({
22
+ onShutdownDrained: () => {
23
+ stopSenderWorker();
24
+ stopOrchestrator();
25
+ closeDb();
26
+ },
27
+ });
17
28
  // Sender worker drains outbound queue → channel adapters. Started
18
29
  // before the socket so it's ready when handleReply enqueues rows.
19
30
  startSenderWorker();
@@ -30,17 +41,17 @@ async function main() {
30
41
  await handleReply(job, result, {});
31
42
  }).catch((err) => logger.error({ err }, 'replay failed'));
32
43
  }
44
+ // Graceful shutdown: signal handler writes a 'shutdown' row to the
45
+ // control table; orchestrator picks it up, drains in-flight work,
46
+ // then runs onShutdownDrained (stops workers, closes DB) and exits.
47
+ // A 30s timer inside the orchestrator force-exits if drain hangs.
33
48
  process.on('SIGINT', () => {
34
- logger.info('SIGINT received, shutting down');
35
- stopSenderWorker();
36
- closeDb();
37
- process.exit(0);
49
+ logger.info('SIGINT received, requesting graceful shutdown');
50
+ requestShutdown('SIGINT');
38
51
  });
39
52
  process.on('SIGTERM', () => {
40
- logger.info('SIGTERM received, shutting down');
41
- stopSenderWorker();
42
- closeDb();
43
- process.exit(0);
53
+ logger.info('SIGTERM received, requesting graceful shutdown');
54
+ requestShutdown('SIGTERM');
44
55
  });
45
56
  main().catch((err) => {
46
57
  logger.error({ err }, 'fatal error during boot');
@@ -0,0 +1,40 @@
1
+ // Helpers for the control table — the bot's runtime signalling
2
+ // channel. SIGTERM, /shutdown chat command, or external trigger all
3
+ // insert a control row; the orchestrator picks it up on its next tick
4
+ // and acts.
5
+ //
6
+ // Single-row-per-key: PK on `key` gives natural upsert semantics.
7
+ import { eq } from 'drizzle-orm';
8
+ import { getDb } from '../db/index.js';
9
+ import { control } from '../db/schema.js';
10
+ export function requestControl(key, value = null, requestedBy = null) {
11
+ const db = getDb();
12
+ const now = Math.floor(Date.now() / 1000);
13
+ db.insert(control)
14
+ .values({ key, value, requestedBy, requestedAt: now })
15
+ .onConflictDoUpdate({
16
+ target: control.key,
17
+ set: { value, requestedBy, requestedAt: now },
18
+ })
19
+ .run();
20
+ }
21
+ export function readControl(key) {
22
+ const db = getDb();
23
+ const row = db.select().from(control).where(eq(control.key, key)).get();
24
+ if (!row)
25
+ return null;
26
+ return {
27
+ value: row.value,
28
+ requestedBy: row.requestedBy,
29
+ requestedAt: row.requestedAt,
30
+ };
31
+ }
32
+ export function clearControl(key) {
33
+ const db = getDb();
34
+ const result = db
35
+ .delete(control)
36
+ .where(eq(control.key, key))
37
+ .returning({ key: control.key })
38
+ .all();
39
+ return result.length > 0;
40
+ }
@@ -0,0 +1,50 @@
1
+ // Maps a fired cron row's payload into the right target queue.
2
+ // Called by the orchestrator each tick for due rows.
3
+ //
4
+ // Currently supports: outbound. inbound, async, memory_writes ride
5
+ // in on later phases — when those queues exist, add their dispatch
6
+ // here and a cron can fire into them with no other changes.
7
+ import { logger } from '../logger.js';
8
+ import { enqueueOutbound } from './outbound.js';
9
+ export function dispatchCron(row) {
10
+ let payload;
11
+ try {
12
+ payload = JSON.parse(row.payload);
13
+ }
14
+ catch (err) {
15
+ logger.error({ err, id: row.id, name: row.name }, 'cron payload not JSON');
16
+ return;
17
+ }
18
+ switch (row.enqueueInto) {
19
+ case 'outbound':
20
+ dispatchOutbound(row, payload);
21
+ return;
22
+ case 'inbound':
23
+ case 'async':
24
+ case 'memory_writes':
25
+ logger.warn({ name: row.name, target: row.enqueueInto }, 'cron target queue not yet wired (phase pending)');
26
+ return;
27
+ default:
28
+ logger.error({ name: row.name, target: row.enqueueInto }, 'cron has unknown target queue');
29
+ }
30
+ }
31
+ function dispatchOutbound(row, payload) {
32
+ if (!isOutboundPayload(payload)) {
33
+ logger.error({ id: row.id, payload }, 'cron outbound payload malformed');
34
+ return;
35
+ }
36
+ enqueueOutbound({
37
+ ...payload,
38
+ // Recurring crons MUST NOT collide with prior firings; embed the
39
+ // current tick in the idempotency key. One-shots can supply their
40
+ // own.
41
+ idempotencyKey: payload.idempotencyKey ??
42
+ `cron-${row.name}-${Math.floor(Date.now() / 1000)}`,
43
+ });
44
+ }
45
+ function isOutboundPayload(p) {
46
+ if (!p || typeof p !== 'object')
47
+ return false;
48
+ const o = p;
49
+ return typeof o.address === 'string' && typeof o.kind === 'string';
50
+ }
@@ -0,0 +1,205 @@
1
+ // Cron table helpers. Orchestrator polls `listDueCrons()` each tick;
2
+ // each due row gets its `payload` enqueued into its target queue,
3
+ // then `markCronFired()` updates `lastRunAt` and recomputes
4
+ // `nextRunAt` (or deletes if one-shot).
5
+ //
6
+ // Recurrence formats supported:
7
+ // '@every <n><unit>' n>0 integer; unit s|m|h|d
8
+ // '@daily HH:MM' owner-tz local
9
+ // '@weekly DOW HH:MM' owner-tz local; DOW = mon..sun
10
+ //
11
+ // No general cron expression parser — every existing setInterval in
12
+ // the bot maps to one of the three forms above. Adding cron parsing
13
+ // is straightforward later if we need it.
14
+ import { and, asc, eq, lte } from 'drizzle-orm';
15
+ import { config } from '../config.js';
16
+ import { getDb } from '../db/index.js';
17
+ import { logger } from '../logger.js';
18
+ import { crons } from '../db/schema.js';
19
+ // Idempotent for named recurring crons: re-inserting the same name
20
+ // updates the recurrence/payload but leaves nextRunAt alone (so we
21
+ // don't reset the firing schedule on every boot).
22
+ export function enqueueCron(input) {
23
+ const db = getDb();
24
+ const now = Math.floor(Date.now() / 1000);
25
+ const enabled = (input.enabled ?? true) ? 1 : 0;
26
+ if (input.recurrence) {
27
+ const existing = db
28
+ .select()
29
+ .from(crons)
30
+ .where(eq(crons.name, input.name))
31
+ .get();
32
+ if (existing) {
33
+ const updated = db
34
+ .update(crons)
35
+ .set({
36
+ enqueueInto: input.enqueueInto,
37
+ payload: JSON.stringify(input.payload),
38
+ recurrence: input.recurrence,
39
+ enabled,
40
+ })
41
+ .where(eq(crons.name, input.name))
42
+ .returning()
43
+ .get();
44
+ return updated;
45
+ }
46
+ }
47
+ const firstRunAt = input.firstRunAt ?? computeNextRun(input.recurrence, now);
48
+ if (firstRunAt === null) {
49
+ throw new Error(`enqueueCron(${input.name}): one-shot requires firstRunAt`);
50
+ }
51
+ return db
52
+ .insert(crons)
53
+ .values({
54
+ name: input.name,
55
+ enqueueInto: input.enqueueInto,
56
+ payload: JSON.stringify(input.payload),
57
+ recurrence: input.recurrence,
58
+ nextRunAt: firstRunAt,
59
+ lastRunAt: null,
60
+ enabled,
61
+ createdAt: now,
62
+ })
63
+ .returning()
64
+ .get();
65
+ }
66
+ export function listDueCrons(asOf = Math.floor(Date.now() / 1000)) {
67
+ const db = getDb();
68
+ return db
69
+ .select()
70
+ .from(crons)
71
+ .where(and(eq(crons.enabled, 1), lte(crons.nextRunAt, asOf)))
72
+ .orderBy(asc(crons.nextRunAt))
73
+ .all();
74
+ }
75
+ // Called by orchestrator after the cron's payload has been enqueued
76
+ // into its target queue. Recurring crons get nextRunAt advanced;
77
+ // one-shots get deleted.
78
+ export function markCronFired(row) {
79
+ const db = getDb();
80
+ const now = Math.floor(Date.now() / 1000);
81
+ if (row.recurrence === null) {
82
+ db.delete(crons).where(eq(crons.id, row.id)).run();
83
+ return;
84
+ }
85
+ const next = computeNextRun(row.recurrence, now);
86
+ if (next === null) {
87
+ logger.error({ id: row.id, name: row.name, recurrence: row.recurrence }, 'cron has unparseable recurrence after firing; disabling');
88
+ db.update(crons)
89
+ .set({ enabled: 0, lastRunAt: now })
90
+ .where(eq(crons.id, row.id))
91
+ .run();
92
+ return;
93
+ }
94
+ db.update(crons)
95
+ .set({ lastRunAt: now, nextRunAt: next })
96
+ .where(eq(crons.id, row.id))
97
+ .run();
98
+ }
99
+ export function deleteCron(name) {
100
+ const db = getDb();
101
+ const result = db
102
+ .delete(crons)
103
+ .where(eq(crons.name, name))
104
+ .returning({ id: crons.id })
105
+ .all();
106
+ return result.length > 0;
107
+ }
108
+ export function setCronEnabled(name, enabled) {
109
+ const db = getDb();
110
+ const result = db
111
+ .update(crons)
112
+ .set({ enabled: enabled ? 1 : 0 })
113
+ .where(eq(crons.name, name))
114
+ .returning({ id: crons.id })
115
+ .all();
116
+ return result.length > 0;
117
+ }
118
+ // ──────────────────────────────────────────────────────────────────
119
+ // Recurrence parser
120
+ // ──────────────────────────────────────────────────────────────────
121
+ const EVERY_RE = /^@every\s+(\d+)\s*([smhd])$/;
122
+ const DAILY_RE = /^@daily\s+(\d{1,2}):(\d{2})$/;
123
+ const WEEKLY_RE = /^@weekly\s+(mon|tue|wed|thu|fri|sat|sun)\s+(\d{1,2}):(\d{2})$/i;
124
+ const UNIT_SECONDS = {
125
+ s: 1, m: 60, h: 3600, d: 86400,
126
+ };
127
+ const DOW_INDEX = {
128
+ sun: 0, mon: 1, tue: 2, wed: 3, thu: 4, fri: 5, sat: 6,
129
+ };
130
+ // Returns the next-run timestamp (unix seconds) for a recurrence, or
131
+ // null for an unparseable format.
132
+ export function computeNextRun(recurrence, nowSec) {
133
+ if (!recurrence)
134
+ return null;
135
+ const everyMatch = EVERY_RE.exec(recurrence);
136
+ if (everyMatch) {
137
+ const n = parseInt(everyMatch[1], 10);
138
+ const unit = everyMatch[2];
139
+ if (n <= 0)
140
+ return null;
141
+ return nowSec + n * (UNIT_SECONDS[unit] ?? 0);
142
+ }
143
+ const dailyMatch = DAILY_RE.exec(recurrence);
144
+ if (dailyMatch) {
145
+ return nextLocalHourMinute(nowSec, parseInt(dailyMatch[1], 10), parseInt(dailyMatch[2], 10), null);
146
+ }
147
+ const weeklyMatch = WEEKLY_RE.exec(recurrence);
148
+ if (weeklyMatch) {
149
+ const dow = DOW_INDEX[weeklyMatch[1].toLowerCase()];
150
+ return nextLocalHourMinute(nowSec, parseInt(weeklyMatch[2], 10), parseInt(weeklyMatch[3], 10), dow);
151
+ }
152
+ return null;
153
+ }
154
+ // Compute the next unix-seconds at HH:MM in the owner timezone,
155
+ // optionally constrained to a day-of-week. Always returns a moment
156
+ // strictly in the future (> nowSec).
157
+ function nextLocalHourMinute(nowSec, hour, minute, dayOfWeek) {
158
+ const tz = config.owner.timezone;
159
+ const fmt = new Intl.DateTimeFormat('en-CA', {
160
+ timeZone: tz,
161
+ year: 'numeric', month: '2-digit', day: '2-digit',
162
+ hour: '2-digit', minute: '2-digit', second: '2-digit',
163
+ weekday: 'short', hour12: false,
164
+ });
165
+ // Walk forward in 1-hour steps from now until we land on the right
166
+ // (day, dow) and the candidate moment is in the future. Cheaper than
167
+ // it sounds — at most 7*24 = 168 iterations.
168
+ for (let step = 0; step < 24 * 8; step++) {
169
+ const candidate = new Date((nowSec + step * 3600) * 1000);
170
+ const parts = fmt.formatToParts(candidate);
171
+ const get = (type) => parts.find((p) => p.type === type)?.value ?? '';
172
+ const cYear = parseInt(get('year'), 10);
173
+ const cMonth = parseInt(get('month'), 10) - 1;
174
+ const cDay = parseInt(get('day'), 10);
175
+ const wdName = get('weekday').toLowerCase();
176
+ const cDow = DOW_INDEX[wdName] ?? -1;
177
+ if (dayOfWeek !== null && cDow !== dayOfWeek)
178
+ continue;
179
+ // Build a UTC instant for HH:MM on (cYear,cMonth,cDay) in the tz.
180
+ // Easier path: format candidate at hour/minute and re-parse via
181
+ // the timezone-offset trick.
182
+ const candidateLocal = makeDateInTz(cYear, cMonth, cDay, hour, minute, tz);
183
+ if (candidateLocal > nowSec)
184
+ return candidateLocal;
185
+ }
186
+ // Fallback (shouldn't be reachable): an hour from now.
187
+ return nowSec + 3600;
188
+ }
189
+ // Build a unix-seconds for a given Y/M/D HH:MM in a named timezone.
190
+ // Done by guess-and-correct: assume the input is UTC, see how the tz
191
+ // renders that instant, take the delta, apply it.
192
+ function makeDateInTz(year, month, day, hour, minute, tz) {
193
+ const guessUtcMs = Date.UTC(year, month, day, hour, minute, 0);
194
+ const fmt = new Intl.DateTimeFormat('en-US', {
195
+ timeZone: tz,
196
+ year: 'numeric', month: '2-digit', day: '2-digit',
197
+ hour: '2-digit', minute: '2-digit', second: '2-digit',
198
+ hour12: false,
199
+ });
200
+ const parts = fmt.formatToParts(new Date(guessUtcMs));
201
+ const get = (type) => parts.find((p) => p.type === type)?.value ?? '0';
202
+ const renderedUtcMs = Date.UTC(parseInt(get('year'), 10), parseInt(get('month'), 10) - 1, parseInt(get('day'), 10), parseInt(get('hour'), 10), parseInt(get('minute'), 10), parseInt(get('second'), 10));
203
+ const offsetMs = guessUtcMs - renderedUtcMs;
204
+ return Math.floor((guessUtcMs + offsetMs) / 1000);
205
+ }
@@ -0,0 +1,175 @@
1
+ // Bot-wide orchestrator. One process-wide instance. Polls every
2
+ // ~500ms and does the cross-cutting work no single worker should
3
+ // own:
4
+ // - Read control table → apply shutdown/pause/reload signals.
5
+ // - Reclaim stuck claims on outbound (and later: async, browser,
6
+ // memory_writes).
7
+ // - Mark dead workers (last_seen past threshold).
8
+ // - Poll the cron table → enqueue due jobs (Phase 2.2; not yet).
9
+ // - Log queue depths to a metrics buffer (Phase 7; not yet).
10
+ //
11
+ // Distinct from the sender worker: sender pulls from outbound and
12
+ // sends. Orchestrator pulls signals and metadata; it dispatches but
13
+ // doesn't do per-row work itself.
14
+ import { hostname } from 'os';
15
+ import { and, eq, lt, ne } from 'drizzle-orm';
16
+ import { getDb } from '../db/index.js';
17
+ import { workers } from '../db/schema.js';
18
+ import { logger } from '../logger.js';
19
+ import { reclaimStuckOutbound } from './outbound.js';
20
+ import { clearControl, readControl, requestControl } from './control.js';
21
+ import { listDueCrons, markCronFired } from './crons.js';
22
+ import { dispatchCron } from './cron-dispatch.js';
23
+ const TICK_INTERVAL_MS = 500;
24
+ const HEARTBEAT_INTERVAL_MS = 5_000;
25
+ const WORKER_DEAD_AFTER_SECONDS = 30;
26
+ const SHUTDOWN_GRACE_MS = 30_000; // total drain window before force-exit
27
+ let workerId = null;
28
+ let stopping = false;
29
+ let draining = false;
30
+ let tickTimer = null;
31
+ let heartbeatTimer = null;
32
+ let exitHook = null;
33
+ function newOrchestratorId() {
34
+ return `${hostname()}-${process.pid}-orchestrator-0`;
35
+ }
36
+ function registerSelf(id) {
37
+ const db = getDb();
38
+ const now = Math.floor(Date.now() / 1000);
39
+ db.insert(workers)
40
+ .values({
41
+ id,
42
+ kind: 'orchestrator',
43
+ status: 'idle',
44
+ currentJob: null,
45
+ lastSeen: now,
46
+ startedAt: now,
47
+ })
48
+ .onConflictDoUpdate({
49
+ target: workers.id,
50
+ set: { status: 'idle', currentJob: null, lastSeen: now, startedAt: now },
51
+ })
52
+ .run();
53
+ }
54
+ function heartbeat(id) {
55
+ const db = getDb();
56
+ db.update(workers)
57
+ .set({ lastSeen: Math.floor(Date.now() / 1000) })
58
+ .where(eq(workers.id, id))
59
+ .run();
60
+ }
61
+ // Mark workers as dead when their last_seen has aged past the
62
+ // threshold. Used as a liveness signal in observability queries and
63
+ // (eventually) to reclaim their claimed jobs across all queues.
64
+ function markDeadWorkers() {
65
+ const db = getDb();
66
+ const cutoff = Math.floor(Date.now() / 1000) - WORKER_DEAD_AFTER_SECONDS;
67
+ const result = db
68
+ .update(workers)
69
+ .set({ status: 'dead' })
70
+ .where(and(lt(workers.lastSeen, cutoff), ne(workers.status, 'dead')))
71
+ .returning({ id: workers.id, kind: workers.kind })
72
+ .all();
73
+ for (const w of result) {
74
+ logger.warn({ id: w.id, kind: w.kind }, 'worker marked dead (no heartbeat)');
75
+ }
76
+ return result.length;
77
+ }
78
+ function busyWorkerCount() {
79
+ const db = getDb();
80
+ const row = db
81
+ .select({ id: workers.id })
82
+ .from(workers)
83
+ .where(eq(workers.status, 'busy'))
84
+ .all();
85
+ return row.length;
86
+ }
87
+ async function tick(id) {
88
+ try {
89
+ const ctl = readControl('shutdown');
90
+ if (ctl && !draining) {
91
+ logger.info({ requestedBy: ctl.requestedBy }, 'shutdown requested via control table');
92
+ draining = true;
93
+ // Mark ourselves draining so observability shows it.
94
+ const db = getDb();
95
+ db.update(workers)
96
+ .set({ status: 'draining' })
97
+ .where(eq(workers.id, id))
98
+ .run();
99
+ }
100
+ // Cross-queue housekeeping. More queues land in later phases.
101
+ const reclaimed = reclaimStuckOutbound();
102
+ if (reclaimed > 0) {
103
+ logger.info({ reclaimed }, 'reclaimed stuck outbound rows');
104
+ }
105
+ // Fire any due crons. Order: dispatch each in turn; if dispatch
106
+ // throws (it shouldn't — dispatch swallows), the cron is NOT
107
+ // marked fired and we'll retry on the next tick.
108
+ if (!draining) {
109
+ const due = listDueCrons();
110
+ for (const row of due) {
111
+ try {
112
+ dispatchCron(row);
113
+ markCronFired(row);
114
+ }
115
+ catch (err) {
116
+ logger.error({ err, name: row.name }, 'cron dispatch crashed');
117
+ }
118
+ }
119
+ }
120
+ markDeadWorkers();
121
+ if (draining) {
122
+ const busy = busyWorkerCount();
123
+ if (busy === 0) {
124
+ logger.info('all workers idle, exiting cleanly');
125
+ clearControl('shutdown');
126
+ if (exitHook) {
127
+ await exitHook();
128
+ }
129
+ process.exit(0);
130
+ }
131
+ }
132
+ }
133
+ catch (err) {
134
+ logger.error({ err }, 'orchestrator tick error');
135
+ }
136
+ }
137
+ export function startOrchestrator(opts = {}) {
138
+ if (workerId) {
139
+ logger.warn('orchestrator already started; ignoring');
140
+ return;
141
+ }
142
+ workerId = newOrchestratorId();
143
+ exitHook = opts.onShutdownDrained ?? null;
144
+ registerSelf(workerId);
145
+ heartbeatTimer = setInterval(() => workerId && heartbeat(workerId), HEARTBEAT_INTERVAL_MS);
146
+ const id = workerId;
147
+ tickTimer = setInterval(() => {
148
+ void tick(id);
149
+ }, TICK_INTERVAL_MS);
150
+ logger.info({ workerId }, 'orchestrator started');
151
+ }
152
+ export function stopOrchestrator() {
153
+ stopping = true;
154
+ if (tickTimer)
155
+ clearInterval(tickTimer);
156
+ if (heartbeatTimer)
157
+ clearInterval(heartbeatTimer);
158
+ }
159
+ // Public entry point for "begin graceful shutdown." Inserts the
160
+ // control row + sets a force-exit timer so we don't hang forever if
161
+ // some worker refuses to drain.
162
+ export function requestShutdown(by) {
163
+ if (draining)
164
+ return;
165
+ requestControl('shutdown', 'requested', by);
166
+ setTimeout(() => {
167
+ if (!stopping) {
168
+ logger.warn({ graceMs: SHUTDOWN_GRACE_MS }, 'graceful shutdown timed out, forcing exit');
169
+ process.exit(1);
170
+ }
171
+ }, SHUTDOWN_GRACE_MS).unref();
172
+ }
173
+ export function isDraining() {
174
+ return draining;
175
+ }
@@ -0,0 +1,14 @@
1
+ CREATE TABLE `crons` (
2
+ `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
3
+ `name` text NOT NULL,
4
+ `enqueue_into` text NOT NULL,
5
+ `payload` text NOT NULL,
6
+ `recurrence` text,
7
+ `next_run_at` integer NOT NULL,
8
+ `last_run_at` integer,
9
+ `enabled` integer DEFAULT 1 NOT NULL,
10
+ `created_at` integer NOT NULL
11
+ );
12
+ --> statement-breakpoint
13
+ CREATE INDEX `crons_by_due` ON `crons` (`enabled`,`next_run_at`);--> statement-breakpoint
14
+ CREATE UNIQUE INDEX `crons_name_uq` ON `crons` (`name`) WHERE "crons"."recurrence" IS NOT NULL;
@@ -0,0 +1,468 @@
1
+ {
2
+ "version": "6",
3
+ "dialect": "sqlite",
4
+ "id": "417b2708-624e-416f-af59-bee5d5e38556",
5
+ "prevId": "56b9231e-919c-4b8d-9a77-68735c33cbb0",
6
+ "tables": {
7
+ "control": {
8
+ "name": "control",
9
+ "columns": {
10
+ "key": {
11
+ "name": "key",
12
+ "type": "text",
13
+ "primaryKey": true,
14
+ "notNull": true,
15
+ "autoincrement": false
16
+ },
17
+ "value": {
18
+ "name": "value",
19
+ "type": "text",
20
+ "primaryKey": false,
21
+ "notNull": false,
22
+ "autoincrement": false
23
+ },
24
+ "requested_by": {
25
+ "name": "requested_by",
26
+ "type": "text",
27
+ "primaryKey": false,
28
+ "notNull": false,
29
+ "autoincrement": false
30
+ },
31
+ "requested_at": {
32
+ "name": "requested_at",
33
+ "type": "integer",
34
+ "primaryKey": false,
35
+ "notNull": true,
36
+ "autoincrement": false
37
+ }
38
+ },
39
+ "indexes": {},
40
+ "foreignKeys": {},
41
+ "compositePrimaryKeys": {},
42
+ "uniqueConstraints": {},
43
+ "checkConstraints": {}
44
+ },
45
+ "crons": {
46
+ "name": "crons",
47
+ "columns": {
48
+ "id": {
49
+ "name": "id",
50
+ "type": "integer",
51
+ "primaryKey": true,
52
+ "notNull": true,
53
+ "autoincrement": true
54
+ },
55
+ "name": {
56
+ "name": "name",
57
+ "type": "text",
58
+ "primaryKey": false,
59
+ "notNull": true,
60
+ "autoincrement": false
61
+ },
62
+ "enqueue_into": {
63
+ "name": "enqueue_into",
64
+ "type": "text",
65
+ "primaryKey": false,
66
+ "notNull": true,
67
+ "autoincrement": false
68
+ },
69
+ "payload": {
70
+ "name": "payload",
71
+ "type": "text",
72
+ "primaryKey": false,
73
+ "notNull": true,
74
+ "autoincrement": false
75
+ },
76
+ "recurrence": {
77
+ "name": "recurrence",
78
+ "type": "text",
79
+ "primaryKey": false,
80
+ "notNull": false,
81
+ "autoincrement": false
82
+ },
83
+ "next_run_at": {
84
+ "name": "next_run_at",
85
+ "type": "integer",
86
+ "primaryKey": false,
87
+ "notNull": true,
88
+ "autoincrement": false
89
+ },
90
+ "last_run_at": {
91
+ "name": "last_run_at",
92
+ "type": "integer",
93
+ "primaryKey": false,
94
+ "notNull": false,
95
+ "autoincrement": false
96
+ },
97
+ "enabled": {
98
+ "name": "enabled",
99
+ "type": "integer",
100
+ "primaryKey": false,
101
+ "notNull": true,
102
+ "autoincrement": false,
103
+ "default": 1
104
+ },
105
+ "created_at": {
106
+ "name": "created_at",
107
+ "type": "integer",
108
+ "primaryKey": false,
109
+ "notNull": true,
110
+ "autoincrement": false
111
+ }
112
+ },
113
+ "indexes": {
114
+ "crons_by_due": {
115
+ "name": "crons_by_due",
116
+ "columns": [
117
+ "enabled",
118
+ "next_run_at"
119
+ ],
120
+ "isUnique": false
121
+ },
122
+ "crons_name_uq": {
123
+ "name": "crons_name_uq",
124
+ "columns": [
125
+ "name"
126
+ ],
127
+ "isUnique": true,
128
+ "where": "\"crons\".\"recurrence\" IS NOT NULL"
129
+ }
130
+ },
131
+ "foreignKeys": {},
132
+ "compositePrimaryKeys": {},
133
+ "uniqueConstraints": {},
134
+ "checkConstraints": {}
135
+ },
136
+ "identities": {
137
+ "name": "identities",
138
+ "columns": {
139
+ "person_id": {
140
+ "name": "person_id",
141
+ "type": "text",
142
+ "primaryKey": false,
143
+ "notNull": true,
144
+ "autoincrement": false
145
+ },
146
+ "address": {
147
+ "name": "address",
148
+ "type": "text",
149
+ "primaryKey": false,
150
+ "notNull": true,
151
+ "autoincrement": false
152
+ },
153
+ "added_at": {
154
+ "name": "added_at",
155
+ "type": "integer",
156
+ "primaryKey": false,
157
+ "notNull": true,
158
+ "autoincrement": false
159
+ }
160
+ },
161
+ "indexes": {
162
+ "identities_address_unique": {
163
+ "name": "identities_address_unique",
164
+ "columns": [
165
+ "address"
166
+ ],
167
+ "isUnique": true
168
+ }
169
+ },
170
+ "foreignKeys": {
171
+ "identities_person_id_persons_id_fk": {
172
+ "name": "identities_person_id_persons_id_fk",
173
+ "tableFrom": "identities",
174
+ "tableTo": "persons",
175
+ "columnsFrom": [
176
+ "person_id"
177
+ ],
178
+ "columnsTo": [
179
+ "id"
180
+ ],
181
+ "onDelete": "no action",
182
+ "onUpdate": "no action"
183
+ }
184
+ },
185
+ "compositePrimaryKeys": {
186
+ "identities_person_id_address_pk": {
187
+ "columns": [
188
+ "person_id",
189
+ "address"
190
+ ],
191
+ "name": "identities_person_id_address_pk"
192
+ }
193
+ },
194
+ "uniqueConstraints": {},
195
+ "checkConstraints": {}
196
+ },
197
+ "outbound": {
198
+ "name": "outbound",
199
+ "columns": {
200
+ "id": {
201
+ "name": "id",
202
+ "type": "integer",
203
+ "primaryKey": true,
204
+ "notNull": true,
205
+ "autoincrement": true
206
+ },
207
+ "address": {
208
+ "name": "address",
209
+ "type": "text",
210
+ "primaryKey": false,
211
+ "notNull": true,
212
+ "autoincrement": false
213
+ },
214
+ "kind": {
215
+ "name": "kind",
216
+ "type": "text",
217
+ "primaryKey": false,
218
+ "notNull": true,
219
+ "autoincrement": false
220
+ },
221
+ "text": {
222
+ "name": "text",
223
+ "type": "text",
224
+ "primaryKey": false,
225
+ "notNull": false,
226
+ "autoincrement": false
227
+ },
228
+ "media_path": {
229
+ "name": "media_path",
230
+ "type": "text",
231
+ "primaryKey": false,
232
+ "notNull": false,
233
+ "autoincrement": false
234
+ },
235
+ "media_mime": {
236
+ "name": "media_mime",
237
+ "type": "text",
238
+ "primaryKey": false,
239
+ "notNull": false,
240
+ "autoincrement": false
241
+ },
242
+ "media_bytes": {
243
+ "name": "media_bytes",
244
+ "type": "integer",
245
+ "primaryKey": false,
246
+ "notNull": false,
247
+ "autoincrement": false
248
+ },
249
+ "quote_msg_id": {
250
+ "name": "quote_msg_id",
251
+ "type": "text",
252
+ "primaryKey": false,
253
+ "notNull": false,
254
+ "autoincrement": false
255
+ },
256
+ "idempotency_key": {
257
+ "name": "idempotency_key",
258
+ "type": "text",
259
+ "primaryKey": false,
260
+ "notNull": false,
261
+ "autoincrement": false
262
+ },
263
+ "status": {
264
+ "name": "status",
265
+ "type": "text",
266
+ "primaryKey": false,
267
+ "notNull": true,
268
+ "autoincrement": false
269
+ },
270
+ "attempts": {
271
+ "name": "attempts",
272
+ "type": "integer",
273
+ "primaryKey": false,
274
+ "notNull": true,
275
+ "autoincrement": false,
276
+ "default": 0
277
+ },
278
+ "next_attempt_at": {
279
+ "name": "next_attempt_at",
280
+ "type": "integer",
281
+ "primaryKey": false,
282
+ "notNull": false,
283
+ "autoincrement": false
284
+ },
285
+ "last_error": {
286
+ "name": "last_error",
287
+ "type": "text",
288
+ "primaryKey": false,
289
+ "notNull": false,
290
+ "autoincrement": false
291
+ },
292
+ "claimed_by": {
293
+ "name": "claimed_by",
294
+ "type": "text",
295
+ "primaryKey": false,
296
+ "notNull": false,
297
+ "autoincrement": false
298
+ },
299
+ "claimed_at": {
300
+ "name": "claimed_at",
301
+ "type": "integer",
302
+ "primaryKey": false,
303
+ "notNull": false,
304
+ "autoincrement": false
305
+ },
306
+ "created_at": {
307
+ "name": "created_at",
308
+ "type": "integer",
309
+ "primaryKey": false,
310
+ "notNull": true,
311
+ "autoincrement": false
312
+ },
313
+ "updated_at": {
314
+ "name": "updated_at",
315
+ "type": "integer",
316
+ "primaryKey": false,
317
+ "notNull": true,
318
+ "autoincrement": false
319
+ }
320
+ },
321
+ "indexes": {
322
+ "outbound_by_status_next": {
323
+ "name": "outbound_by_status_next",
324
+ "columns": [
325
+ "status",
326
+ "next_attempt_at"
327
+ ],
328
+ "isUnique": false
329
+ },
330
+ "outbound_by_address": {
331
+ "name": "outbound_by_address",
332
+ "columns": [
333
+ "address"
334
+ ],
335
+ "isUnique": false
336
+ },
337
+ "outbound_idempotency_key_uq": {
338
+ "name": "outbound_idempotency_key_uq",
339
+ "columns": [
340
+ "idempotency_key"
341
+ ],
342
+ "isUnique": true,
343
+ "where": "\"outbound\".\"idempotency_key\" IS NOT NULL"
344
+ }
345
+ },
346
+ "foreignKeys": {},
347
+ "compositePrimaryKeys": {},
348
+ "uniqueConstraints": {},
349
+ "checkConstraints": {}
350
+ },
351
+ "persons": {
352
+ "name": "persons",
353
+ "columns": {
354
+ "id": {
355
+ "name": "id",
356
+ "type": "text",
357
+ "primaryKey": true,
358
+ "notNull": true,
359
+ "autoincrement": false
360
+ },
361
+ "display_name": {
362
+ "name": "display_name",
363
+ "type": "text",
364
+ "primaryKey": false,
365
+ "notNull": false,
366
+ "autoincrement": false
367
+ },
368
+ "timezone": {
369
+ "name": "timezone",
370
+ "type": "text",
371
+ "primaryKey": false,
372
+ "notNull": false,
373
+ "autoincrement": false
374
+ },
375
+ "created_at": {
376
+ "name": "created_at",
377
+ "type": "integer",
378
+ "primaryKey": false,
379
+ "notNull": true,
380
+ "autoincrement": false
381
+ }
382
+ },
383
+ "indexes": {},
384
+ "foreignKeys": {},
385
+ "compositePrimaryKeys": {},
386
+ "uniqueConstraints": {},
387
+ "checkConstraints": {}
388
+ },
389
+ "workers": {
390
+ "name": "workers",
391
+ "columns": {
392
+ "id": {
393
+ "name": "id",
394
+ "type": "text",
395
+ "primaryKey": true,
396
+ "notNull": true,
397
+ "autoincrement": false
398
+ },
399
+ "kind": {
400
+ "name": "kind",
401
+ "type": "text",
402
+ "primaryKey": false,
403
+ "notNull": true,
404
+ "autoincrement": false
405
+ },
406
+ "status": {
407
+ "name": "status",
408
+ "type": "text",
409
+ "primaryKey": false,
410
+ "notNull": true,
411
+ "autoincrement": false
412
+ },
413
+ "current_job": {
414
+ "name": "current_job",
415
+ "type": "text",
416
+ "primaryKey": false,
417
+ "notNull": false,
418
+ "autoincrement": false
419
+ },
420
+ "last_seen": {
421
+ "name": "last_seen",
422
+ "type": "integer",
423
+ "primaryKey": false,
424
+ "notNull": true,
425
+ "autoincrement": false
426
+ },
427
+ "started_at": {
428
+ "name": "started_at",
429
+ "type": "integer",
430
+ "primaryKey": false,
431
+ "notNull": true,
432
+ "autoincrement": false
433
+ }
434
+ },
435
+ "indexes": {
436
+ "workers_by_kind_status": {
437
+ "name": "workers_by_kind_status",
438
+ "columns": [
439
+ "kind",
440
+ "status"
441
+ ],
442
+ "isUnique": false
443
+ },
444
+ "workers_by_last_seen": {
445
+ "name": "workers_by_last_seen",
446
+ "columns": [
447
+ "last_seen"
448
+ ],
449
+ "isUnique": false
450
+ }
451
+ },
452
+ "foreignKeys": {},
453
+ "compositePrimaryKeys": {},
454
+ "uniqueConstraints": {},
455
+ "checkConstraints": {}
456
+ }
457
+ },
458
+ "views": {},
459
+ "enums": {},
460
+ "_meta": {
461
+ "schemas": {},
462
+ "tables": {},
463
+ "columns": {}
464
+ },
465
+ "internal": {
466
+ "indexes": {}
467
+ }
468
+ }
@@ -15,6 +15,13 @@
15
15
  "when": 1779669712372,
16
16
  "tag": "0001_phase1_outbound",
17
17
  "breakpoints": true
18
+ },
19
+ {
20
+ "idx": 2,
21
+ "version": "6",
22
+ "when": 1779671387894,
23
+ "tag": "0002_phase2_crons",
24
+ "breakpoints": true
18
25
  }
19
26
  ]
20
27
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@c4t4/heyamigo",
3
- "version": "0.9.2",
3
+ "version": "0.9.4",
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",