@hogsend/engine 0.4.0 → 0.6.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.
@@ -1,12 +1,12 @@
1
1
  import type { BucketMeta } from "@hogsend/core";
2
+ import { getAnalytics } from "./analytics-singleton.js";
2
3
  import type { Logger } from "./logger.js";
3
- import { getPostHog } from "./posthog.js";
4
4
 
5
5
  /**
6
6
  * Optional PostHog person-property mirror for a bucket transition (Section 12).
7
7
  *
8
8
  * OFF BY DEFAULT — a no-op unless `meta.syncToPostHog === true`. Also a no-op
9
- * without `POSTHOG_API_KEY` (`getPostHog()` returns undefined), so self-host
9
+ * without `POSTHOG_API_KEY` (the injected analytics is undefined), so self-host
10
10
  * setups that omit PostHog silently do nothing — documented, not broken.
11
11
  *
12
12
  * On JOIN it `$set`s a boolean person property `true`; on LEAVE it `$unset`s the
@@ -32,8 +32,10 @@ export function syncBucketToPostHog(opts: {
32
32
 
33
33
  if (!bucket.syncToPostHog) return;
34
34
 
35
- const posthog = getPostHog();
36
- if (!posthog) return; // no POSTHOG_API_KEY silent no-op
35
+ // The injected analytics instance (set by createHogsendClient). Same object as
36
+ // container.analytics; undefined when POSTHOG_API_KEY is unset.
37
+ const posthog = getAnalytics();
38
+ if (!posthog) return; // no analytics configured → silent no-op
37
39
 
38
40
  const propertyKey =
39
41
  bucket.postHogPropertyKey ?? `hogsend_bucket_${bucket.id}`;
@@ -1,4 +1,11 @@
1
- import type { DurationObject } from "@hogsend/core";
1
+ import type {
2
+ BatchEmailItem,
3
+ DurationObject,
4
+ SendEmailOptions,
5
+ SendResult,
6
+ WebhookEventType,
7
+ WebhookHandlerMap,
8
+ } from "@hogsend/core";
2
9
  import type {
3
10
  EmailServiceRenderOptions,
4
11
  EmailServiceRenderResult,
@@ -7,20 +14,22 @@ import type {
7
14
  TemplateRegistry,
8
15
  TemplateRegistryMap,
9
16
  } from "@hogsend/email";
10
- import type {
11
- BatchEmailItem,
12
- SendEmailOptions,
13
- SendResult,
14
- WebhookEventType,
15
- WebhookHandlerMap,
16
- } from "@hogsend/plugin-resend";
17
17
  import type { Logger } from "./logger.js";
18
18
 
19
19
  export type {
20
20
  BatchEmailItem,
21
21
  SendEmailOptions,
22
22
  SendResult,
23
- } from "@hogsend/plugin-resend";
23
+ } from "@hogsend/core";
24
+
25
+ /**
26
+ * Input to the mailer's low-level {@link EmailService.sendRaw}: the provider
27
+ * contract's `SendEmailOptions`, but `from` is optional — the mailer resolves it
28
+ * from `config.defaultFrom` when absent (see `resolveFrom` in mailer.ts). The
29
+ * wire contract keeps `from` required because the provider always receives a
30
+ * resolved address.
31
+ */
32
+ export type SendRawOptions = Omit<SendEmailOptions, "from"> & { from?: string };
24
33
 
25
34
  // ---------------------------------------------------------------------------
26
35
  // Tracked email (high-level API)
@@ -136,7 +145,7 @@ export interface EmailService {
136
145
  options: EmailServiceSendOptions<K>,
137
146
  ): Promise<TrackedSendResult>;
138
147
 
139
- sendRaw(options: SendEmailOptions): Promise<SendResult>;
148
+ sendRaw(options: SendRawOptions): Promise<SendResult>;
140
149
 
141
150
  sendBatch(options: { emails: BatchEmailItem[] }): Promise<{
142
151
  results: SendResult[];
package/src/lib/email.ts CHANGED
@@ -3,21 +3,23 @@ import type {
3
3
  EmailService,
4
4
  EmailServiceSendOptions,
5
5
  } from "./email-service-types.js";
6
+ import { createSingleton } from "./singleton.js";
6
7
 
7
- let _service: EmailService | null = null;
8
+ const _service = createSingleton<EmailService>("Email service");
8
9
 
9
- export function setEmailService(service: EmailService): void {
10
- _service = service;
11
- }
10
+ export const setEmailService = _service.set;
12
11
 
13
- function getService(): EmailService {
14
- if (!_service) {
15
- throw new Error(
16
- "Email service not initialized. Call setEmailService() at startup.",
17
- );
18
- }
19
- return _service;
20
- }
12
+ /**
13
+ * The injected {@link EmailService} (set by `createHogsendClient` →
14
+ * `setEmailService`). Exposed so module-level task-execution sites with no
15
+ * client reference (the `send-email` Hatchet task, the alerting task) deliver
16
+ * through the same provider-backed mailer the container built, honoring a
17
+ * swapped provider instead of constructing a raw Resend client of their own.
18
+ * Throws if read before the container has installed the service — same
19
+ * guarantee as the journey/bucket registry singletons (the container always
20
+ * runs first in both the API and worker processes).
21
+ */
22
+ export const getEmailService = _service.get;
21
23
 
22
24
  export interface SendEmailOptions {
23
25
  to: string;
@@ -37,7 +39,7 @@ export interface SendEmailResult {
37
39
  export async function sendEmail(
38
40
  opts: SendEmailOptions,
39
41
  ): Promise<SendEmailResult> {
40
- const service = getService();
42
+ const service = getEmailService();
41
43
 
42
44
  let unsubscribeUrl: string | undefined;
43
45
  if (process.env.API_PUBLIC_URL && process.env.BETTER_AUTH_SECRET) {
package/src/lib/mailer.ts CHANGED
@@ -1,3 +1,10 @@
1
+ import type {
2
+ BatchEmailItem,
3
+ EmailProvider,
4
+ WebhookEvent,
5
+ WebhookEventType,
6
+ WebhookHandlerMap,
7
+ } from "@hogsend/core";
1
8
  import type { Database } from "@hogsend/db";
2
9
  import { emailPreferences, emailSends } from "@hogsend/db";
3
10
  import type {
@@ -6,13 +13,6 @@ import type {
6
13
  TemplateName,
7
14
  } from "@hogsend/email";
8
15
  import { getTemplate, renderToHtml, renderToPlainText } from "@hogsend/email";
9
- import type {
10
- BatchEmailItem,
11
- EmailProvider,
12
- WebhookEvent,
13
- WebhookEventType,
14
- WebhookHandlerMap,
15
- } from "@hogsend/plugin-resend";
16
16
  import { eq, sql } from "drizzle-orm";
17
17
  import type {
18
18
  EmailService,
@@ -20,7 +20,7 @@ import type {
20
20
  EmailServiceSendOptions,
21
21
  EmailServiceWebhookOptions,
22
22
  EmailServiceWebhookResult,
23
- SendEmailOptions,
23
+ SendRawOptions,
24
24
  SendResult,
25
25
  TrackedSendResult,
26
26
  } from "./email-service-types.js";
@@ -125,7 +125,7 @@ export function createTrackedMailer(
125
125
  };
126
126
  },
127
127
 
128
- async sendRaw(options: SendEmailOptions): Promise<SendResult> {
128
+ async sendRaw(options: SendRawOptions): Promise<SendResult> {
129
129
  return provider.send({ ...options, from: resolveFrom(options.from) });
130
130
  },
131
131
 
@@ -1,3 +1,5 @@
1
+ import { getEmailService } from "./email.js";
2
+
1
3
  export async function sendWebhook(
2
4
  url: string,
3
5
  payload: Record<string, unknown>,
@@ -32,25 +34,22 @@ export async function sendSlackNotification(
32
34
  return sendWebhook(webhookUrl, { text });
33
35
  }
34
36
 
35
- import { createResendClient } from "@hogsend/plugin-resend";
36
-
37
37
  export async function sendEmailNotification(opts: {
38
38
  to: string;
39
39
  subject: string;
40
40
  body: string;
41
- resendApiKey: string;
42
41
  }): Promise<{ ok: boolean; error?: string }> {
43
42
  try {
44
- const client = createResendClient({ apiKey: opts.resendApiKey });
45
- const { error } = await client.emails.send({
43
+ // Route through the injected EmailProvider (via the mailer's `sendRaw`)
44
+ // instead of constructing a raw Resend client from process.env. The
45
+ // alerting task runs under the worker, where createHogsendClient has already
46
+ // installed the email service.
47
+ await getEmailService().sendRaw({
46
48
  from: "Hogsend Alerts <alerts@hogsend.com>",
47
49
  to: opts.to,
48
50
  subject: opts.subject,
49
51
  html: opts.body,
50
52
  });
51
- if (error) {
52
- return { ok: false, error: error.message };
53
- }
54
53
  return { ok: true };
55
54
  } catch (err) {
56
55
  return {
@@ -1,7 +1,5 @@
1
- import {
2
- createPostHogService,
3
- type PostHogService,
4
- } from "@hogsend/plugin-posthog";
1
+ import type { PostHogService } from "@hogsend/core";
2
+ import { createPostHogService } from "@hogsend/plugin-posthog";
5
3
  import { getRedis } from "./redis.js";
6
4
 
7
5
  let _posthog: PostHogService | undefined;
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Tiny process-singletons: a value set once by `createHogsendClient` at startup
3
+ * and read by module-level task-execution sites that have no client reference of
4
+ * their own (journey/bucket durable tasks, the send-email task). Replaces the
5
+ * hand-rolled `let _x` + set/get/reset trios that were copied across the engine.
6
+ *
7
+ * Two variants:
8
+ * - `createSingleton` — required: `get()` throws if read before it is set.
9
+ * - `createOptionalSingleton` — `undefined` is a legitimate value (e.g. analytics
10
+ * with no `POSTHOG_API_KEY`); `get()` returns `T | undefined` and never throws.
11
+ */
12
+
13
+ export interface Singleton<T> {
14
+ set(value: T): void;
15
+ get(): T;
16
+ reset(): void;
17
+ }
18
+
19
+ export interface OptionalSingleton<T> {
20
+ set(value: T | undefined): void;
21
+ get(): T | undefined;
22
+ reset(): void;
23
+ }
24
+
25
+ export function createSingleton<T>(name: string): Singleton<T> {
26
+ let value: T | undefined;
27
+ return {
28
+ set(next: T): void {
29
+ value = next;
30
+ },
31
+ get(): T {
32
+ if (value === undefined) {
33
+ throw new Error(`${name} not initialized. Call its setter at startup.`);
34
+ }
35
+ return value;
36
+ },
37
+ reset(): void {
38
+ value = undefined;
39
+ },
40
+ };
41
+ }
42
+
43
+ export function createOptionalSingleton<T>(): OptionalSingleton<T> {
44
+ let value: T | undefined;
45
+ return {
46
+ set(next: T | undefined): void {
47
+ value = next;
48
+ },
49
+ get(): T | undefined {
50
+ return value;
51
+ },
52
+ reset(): void {
53
+ value = undefined;
54
+ },
55
+ };
56
+ }
@@ -1,3 +1,4 @@
1
+ import type { EmailProvider } from "@hogsend/core";
1
2
  import type { Database } from "@hogsend/db";
2
3
  import { emailPreferences, emailSends } from "@hogsend/db";
3
4
  import type {
@@ -7,7 +8,6 @@ import type {
7
8
  TemplateRegistry,
8
9
  } from "@hogsend/email";
9
10
  import { getTemplate, renderToHtml } from "@hogsend/email";
10
- import type { EmailProvider } from "@hogsend/plugin-resend";
11
11
  import { eq } from "drizzle-orm";
12
12
  import type {
13
13
  FrequencyCapConfig,
@@ -1,7 +1,7 @@
1
1
  import type { HatchetClient } from "@hatchet-dev/typescript-sdk/v1/index.js";
2
+ import type { PostHogService } from "@hogsend/core";
2
3
  import type { JourneyRegistry } from "@hogsend/core/registry";
3
4
  import { type Database, emailSends, journeyStates } from "@hogsend/db";
4
- import type { PostHogService } from "@hogsend/plugin-posthog";
5
5
  import { eq } from "drizzle-orm";
6
6
  import { ingestEvent } from "./ingestion.js";
7
7
  import type { Logger } from "./logger.js";
@@ -0,0 +1,78 @@
1
+ import type { Logger } from "./logger.js";
2
+ import { getRedis } from "./redis.js";
3
+
4
+ /**
5
+ * Worker liveness heartbeat. The worker and API are separate processes, so the
6
+ * API (and the Studio, via `GET /v1/health`) cannot otherwise tell whether a
7
+ * worker is actually connected — which is exactly the "journeys silently don't
8
+ * fire because the worker isn't running" footgun. The worker writes a TTL'd key
9
+ * to Redis on an interval; readers treat its presence as "a worker is alive".
10
+ *
11
+ * Redis is the channel because the health route already probes Redis and both
12
+ * processes can reach it — no direct process-to-process coupling, no migration.
13
+ * Everything here is best-effort: a missing/unreachable Redis never crashes the
14
+ * worker and simply reads back as "down".
15
+ */
16
+ const HEARTBEAT_KEY = "hogsend:worker:heartbeat";
17
+ const TTL_SECONDS = 30;
18
+ const REFRESH_MS = 10_000;
19
+
20
+ export interface WorkerHeartbeat {
21
+ /** True when a fresh worker heartbeat is present in Redis. */
22
+ alive: boolean;
23
+ /** ISO timestamp the worker last wrote, when alive. */
24
+ lastSeenAt?: string;
25
+ }
26
+
27
+ /**
28
+ * Begin writing the worker heartbeat. Writes once immediately, then refreshes
29
+ * every {@link REFRESH_MS} with a {@link TTL_SECONDS} expiry — so an ungraceful
30
+ * worker death is reflected as "down" within the TTL. Returns a stop function
31
+ * that clears the timer and deletes the key for an immediate "down" signal on
32
+ * graceful shutdown.
33
+ */
34
+ export function startWorkerHeartbeat(logger: Logger): () => Promise<void> {
35
+ let warned = false;
36
+ const write = async () => {
37
+ try {
38
+ await getRedis().set(
39
+ HEARTBEAT_KEY,
40
+ new Date().toISOString(),
41
+ "EX",
42
+ TTL_SECONDS,
43
+ );
44
+ } catch (err) {
45
+ // Log the first failure only — a Redis-less deploy would otherwise spam.
46
+ if (!warned) {
47
+ warned = true;
48
+ logger.debug("Worker heartbeat write failed (Redis unreachable?)", {
49
+ error: err instanceof Error ? err.message : String(err),
50
+ });
51
+ }
52
+ }
53
+ };
54
+
55
+ void write();
56
+ const timer = setInterval(() => void write(), REFRESH_MS);
57
+ // Never hold the process open for the heartbeat alone.
58
+ timer.unref?.();
59
+
60
+ return async () => {
61
+ clearInterval(timer);
62
+ try {
63
+ await getRedis().del(HEARTBEAT_KEY);
64
+ } catch {
65
+ // Best-effort — the TTL expires it anyway.
66
+ }
67
+ };
68
+ }
69
+
70
+ /** Read the current worker heartbeat. Resolves to `{ alive: false }` if Redis is unreachable. */
71
+ export async function getWorkerHeartbeat(): Promise<WorkerHeartbeat> {
72
+ try {
73
+ const lastSeenAt = await getRedis().get(HEARTBEAT_KEY);
74
+ return lastSeenAt ? { alive: true, lastSeenAt } : { alive: false };
75
+ } catch {
76
+ return { alive: false };
77
+ }
78
+ }
@@ -123,6 +123,8 @@ const getRoute = createRoute({
123
123
  id: z.string(),
124
124
  name: z.string(),
125
125
  trigger: z.string(),
126
+ sourceBucketId: z.string().nullable(),
127
+ owned: z.boolean(),
126
128
  }),
127
129
  ),
128
130
  recentMembers: z.array(memberSchema),
@@ -332,27 +334,55 @@ export const bucketsRouter = new OpenAPIHono<AppEnv>()
332
334
  counts[row.status as keyof typeof emptyCounts] = row.count;
333
335
  }
334
336
 
335
- // Which journeys this bucket feeds cross-reference the bucket's emitted
336
- // transition events against the journey registry's trigger index. A journey
337
- // bound to the per-bucket alias `bucket:entered:<id>` (the recommended,
338
- // narrowly-routed binding) or the generic `bucket:entered` is woken by this
339
- // bucket's joins.
337
+ // Which journeys this bucket feeds. Two sources, owned-first:
338
+ // 1. Owned reactions journeys generated by `bucket.on()`, tagged with
339
+ // `sourceBucketId === id`. Discovered by scanning the registry; surfaced
340
+ // with `owned: true`.
341
+ // 2. External bindings — hand-written journeys bound to the bucket's emitted
342
+ // transition events via the per-bucket alias `bucket:entered:<id>` (the
343
+ // recommended, narrowly-routed binding) or the generic `bucket:entered`.
344
+ // Surfaced with `owned: false`. Owned wins on collision.
345
+ const feedsMap = new Map<
346
+ string,
347
+ {
348
+ id: string;
349
+ name: string;
350
+ trigger: string;
351
+ sourceBucketId: string | null;
352
+ owned: boolean;
353
+ }
354
+ >();
355
+
356
+ // Owned reactions: scan the journey registry for sourceBucketId === id.
357
+ for (const journey of registry
358
+ .getAll()
359
+ .filter((j) => j.sourceBucketId === id)) {
360
+ feedsMap.set(journey.id, {
361
+ id: journey.id,
362
+ name: journey.name,
363
+ trigger: journey.trigger.event,
364
+ sourceBucketId: id,
365
+ owned: true,
366
+ });
367
+ }
368
+
369
+ // External bindings: the existing alias + generic cross-reference. Skip any
370
+ // journey already surfaced as owned (owned wins).
340
371
  const feedEvents = [
341
372
  `bucket:entered:${id}`,
342
373
  `bucket:left:${id}`,
343
374
  "bucket:entered",
344
375
  "bucket:left",
345
376
  ];
346
- const feedsMap = new Map<
347
- string,
348
- { id: string; name: string; trigger: string }
349
- >();
350
377
  for (const evt of feedEvents) {
351
378
  for (const journey of registry.getByTriggerEvent(evt)) {
379
+ if (feedsMap.has(journey.id)) continue;
352
380
  feedsMap.set(journey.id, {
353
381
  id: journey.id,
354
382
  name: journey.name,
355
383
  trigger: evt,
384
+ sourceBucketId: journey.sourceBucketId ?? null,
385
+ owned: false,
356
386
  });
357
387
  }
358
388
  }
@@ -4,12 +4,21 @@ import { sql } from "drizzle-orm";
4
4
  import type { AppEnv } from "../app.js";
5
5
  import { API_VERSION } from "../env.js";
6
6
  import { getRedis } from "../lib/redis.js";
7
+ import { getWorkerHeartbeat } from "../lib/worker-heartbeat.js";
7
8
 
8
9
  const componentSchema = z.object({
9
10
  status: z.enum(["up", "down"]),
10
11
  latencyMs: z.number().optional(),
11
12
  });
12
13
 
14
+ // Worker connectivity, derived from the Redis heartbeat. Informational only —
15
+ // the worker is a separate service, so its absence does NOT make the API
16
+ // "degraded" (that would falsely fail the API's own healthcheck).
17
+ const workerComponentSchema = z.object({
18
+ status: z.enum(["up", "down"]),
19
+ lastSeenAt: z.string().optional(),
20
+ });
21
+
13
22
  // Per-track schema version block. Two tracks: `engine` (bundled @hogsend/db
14
23
  // migrations) and `client` (the client repo's own migrations). See
15
24
  // docs/UPGRADING.md "Two-track migrations".
@@ -28,6 +37,7 @@ const healthResponseSchema = z.object({
28
37
  components: z.object({
29
38
  database: componentSchema,
30
39
  redis: componentSchema,
40
+ worker: workerComponentSchema,
31
41
  }),
32
42
  schema: z.object({
33
43
  engine: trackSchema,
@@ -73,7 +83,7 @@ export const healthRouter = new OpenAPIHono<AppEnv>().openapi(
73
83
  async (c) => {
74
84
  const { db, clientJournal } = c.get("container");
75
85
 
76
- const [dbCheck, redisCheck, engine, client] = await Promise.all([
86
+ const [dbCheck, redisCheck, heartbeat, engine, client] = await Promise.all([
77
87
  checkComponent(async () => {
78
88
  await db.execute(sql`SELECT 1`);
79
89
  }),
@@ -86,6 +96,7 @@ export const healthRouter = new OpenAPIHono<AppEnv>().openapi(
86
96
  // host is genuinely unreachable → a truthful "down").
87
97
  await getRedis().ping();
88
98
  }),
99
+ getWorkerHeartbeat(),
89
100
  getEngineSchemaVersion(db),
90
101
  getClientSchemaVersion(db, clientJournal ?? { entries: [] }),
91
102
  ]);
@@ -123,6 +134,10 @@ export const healthRouter = new OpenAPIHono<AppEnv>().openapi(
123
134
  components: {
124
135
  database: dbCheck,
125
136
  redis: redisCheck,
137
+ worker: {
138
+ status: heartbeat.alive ? ("up" as const) : ("down" as const),
139
+ lastSeenAt: heartbeat.lastSeenAt,
140
+ },
126
141
  },
127
142
  },
128
143
  200,
package/src/worker.ts CHANGED
@@ -1,11 +1,15 @@
1
1
  import type { DefinedBucket } from "./buckets/define-bucket.js";
2
- import { selectBucketTasks } from "./buckets/registry.js";
2
+ import {
3
+ selectBucketReactionTasks,
4
+ selectBucketTasks,
5
+ } from "./buckets/registry.js";
3
6
  import type { HogsendClient } from "./container.js";
4
7
  import type { DefinedJourney } from "./journeys/define-journey.js";
5
8
  import { selectJourneyTasks } from "./journeys/registry.js";
9
+ import { reportWorkerReady } from "./lib/boot.js";
6
10
  import { hatchet } from "./lib/hatchet.js";
7
- import { getPostHog } from "./lib/posthog.js";
8
11
  import { getRedisIfConnected } from "./lib/redis.js";
12
+ import { startWorkerHeartbeat } from "./lib/worker-heartbeat.js";
9
13
  import {
10
14
  bucketBackfillTask,
11
15
  enqueueBucketBackfills,
@@ -45,6 +49,15 @@ export function createWorker(opts: CreateWorkerOptions): Worker {
45
49
  // reconcile cron (bucketReconcileTask) is ALWAYS registered in baseWorkflows
46
50
  // below (Section 10), regardless of fastExpiry.
47
51
  const bucketTasks = selectBucketTasks(opts.buckets ?? [], enabledBuckets);
52
+ // Reaction journeys generated by `bucket.on()` desugar to real durable tasks.
53
+ // They are bucket-owned, so they are gated by ENABLED_BUCKETS (NOT
54
+ // ENABLED_JOURNEYS) and wired directly here rather than via the journeys[]
55
+ // array (Section 9). Throws loudly on a reaction-id collision.
56
+ const bucketReactionTasks = selectBucketReactionTasks(
57
+ opts.buckets ?? [],
58
+ enabledBuckets,
59
+ journeys.map((j) => j.meta.id),
60
+ );
48
61
 
49
62
  const baseWorkflows = [
50
63
  sendEmailTask,
@@ -54,6 +67,7 @@ export function createWorker(opts: CreateWorkerOptions): Worker {
54
67
  bucketBackfillTask,
55
68
  ...journeyTasks,
56
69
  ...bucketTasks,
70
+ ...bucketReactionTasks,
57
71
  ];
58
72
  const workflows = [
59
73
  ...baseWorkflows,
@@ -63,21 +77,47 @@ export function createWorker(opts: CreateWorkerOptions): Worker {
63
77
  // Hatchet's worker is created lazily on start so signal wiring can own its
64
78
  // lifecycle. `_worker` is captured for stop().
65
79
  let _worker: Awaited<ReturnType<typeof hatchet.worker>> | undefined;
80
+ let _stopHeartbeat: (() => Promise<void>) | undefined;
66
81
 
67
82
  async function stop(): Promise<void> {
83
+ // Delete the heartbeat first (an immediate "worker down" signal) before the
84
+ // Redis connection is torn down below.
85
+ await _stopHeartbeat?.();
68
86
  await Promise.allSettled([
69
87
  _worker?.stop(),
70
- getPostHog()?.shutdown(),
88
+ // Shut down the injected analytics instance (same object the worker's
89
+ // tasks use), not the module singleton. Undefined when no analytics is
90
+ // configured — the optional chain makes that a no-op.
91
+ container.analytics?.shutdown(),
71
92
  getRedisIfConnected()?.quit(),
72
93
  ]);
73
94
  }
74
95
 
75
96
  async function start(): Promise<void> {
97
+ // Emit BEFORE the Hatchet handshake: proves the process booted past init
98
+ // even while the connection is still establishing (the worker banner /
99
+ // "ready" line only fires once `hatchet.worker()` resolves).
100
+ container.logger.info("Hogsend worker starting", {
101
+ hatchet: container.env.HATCHET_CLIENT_HOST_PORT,
102
+ });
103
+
76
104
  _worker = await hatchet.worker("hogsend-worker", { workflows });
77
105
 
78
- container.logger.info(
79
- `Hogsend worker started with ${journeyTasks.length} journey task(s)`,
80
- );
106
+ reportWorkerReady({
107
+ client: container,
108
+ journeyTasks: journeyTasks.length,
109
+ bucketTasks: bucketTasks.length,
110
+ bucketReactionTasks: bucketReactionTasks.length,
111
+ builtinTasks:
112
+ baseWorkflows.length -
113
+ journeyTasks.length -
114
+ bucketTasks.length -
115
+ bucketReactionTasks.length,
116
+ });
117
+
118
+ // Publish liveness so the API + Studio can show "worker connected"
119
+ // (read via GET /v1/health). Best-effort; never blocks the listener.
120
+ _stopHeartbeat = startWorkerHeartbeat(container.logger);
81
121
 
82
122
  // Boot-time backfill / criteria-change re-eval (Section 6.6 B): diff each
83
123
  // enabled bucket's criteriaHash against bucket_configs and trigger a