@teamscala/service-boot 0.23.516 → 0.23.518

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.
Files changed (86) hide show
  1. package/package.json +232 -1127
  2. package/src/agreement-gate/settings.ts +8 -8
  3. package/src/apply-logger-config.test.ts +46 -0
  4. package/src/apply-logger-config.ts +31 -0
  5. package/src/asset-proxy.test.ts +9 -5
  6. package/src/auth/sign-out.ts +122 -0
  7. package/src/build-client-assets/hydrate-bundle.ts +14 -4
  8. package/src/build-client-assets/manifest.ts +5 -18
  9. package/src/build-client-assets/styles-build.test.ts +11 -8
  10. package/src/build-client-assets/styles-build.ts +14 -63
  11. package/src/build-client-assets.test.ts +0 -161
  12. package/src/build-client-assets.ts +20 -67
  13. package/src/content-gate/token.ts +9 -2
  14. package/src/install-logging.ts +10 -26
  15. package/src/mount-orpc-router.ts +8 -2
  16. package/src/mutable-app-ref.ts +0 -7
  17. package/src/page-server-loader-graph.test.ts +31 -0
  18. package/src/page-server-loader-graph.ts +21 -0
  19. package/src/pg-boss-mount.ts +3 -16
  20. package/src/readiness-gate.ts +0 -4
  21. package/src/register-api-routes.ts +8 -17
  22. package/src/register-page-server-client.ts +6 -0
  23. package/src/register-registry-page-route/meta-title.test.ts +50 -0
  24. package/src/register-registry-page-route/meta-title.ts +1 -1
  25. package/src/register-registry-page-route/page-data-shared.ts +7 -11
  26. package/src/register-registry-page-route/resolve-request-graph.test.ts +1 -1
  27. package/src/register-registry-page-route/resolve-request-graph.ts +1 -2
  28. package/src/register-registry-page-route.ts +14 -16
  29. package/src/resolve-brand-favicon.test.ts +63 -0
  30. package/src/resolve-theme-mode.test.ts +51 -0
  31. package/src/resolvers/fetch-org-brand.test.ts +48 -0
  32. package/src/resolvers/website-by-host-map.test.ts +63 -0
  33. package/src/resolvers/website-by-host-map.ts +15 -3
  34. package/src/run-boot-hook-list.test.ts +47 -0
  35. package/src/run-boot-hooks.test.ts +56 -0
  36. package/src/scala-tools-currency-watchdog.test.ts +43 -0
  37. package/src/scala-tools-currency-watchdog.ts +0 -5
  38. package/src/transfers/presigned-upload.ts +165 -0
  39. package/src/two-phase-minimal-app.test.ts +24 -0
  40. package/src/two-phase-readiness.test.ts +102 -0
  41. package/src/types.test.ts +20 -0
  42. package/src/ui-service.test.ts +24 -0
  43. package/src/ws-boot-self-test.test.ts +43 -0
  44. package/src/build-client-assets/input-fingerprint.ts +0 -92
  45. package/src/build-client-assets/input-hashing.ts +0 -206
  46. package/src/handlers/bun-stable-watch.ts +0 -86
  47. package/src/handlers/messaging-send-reconcile-logic.test.ts +0 -79
  48. package/src/handlers/messaging-send-reconcile-logic.ts +0 -83
  49. package/src/handlers/messaging-send-recording-logic.test.ts +0 -86
  50. package/src/handlers/messaging-send-recording-logic.ts +0 -68
  51. package/src/handlers/messaging-send-recording-watch.test.ts +0 -91
  52. package/src/handlers/messaging-send-recording-watch.ts +0 -244
  53. package/src/handlers/messaging-send-sweep-selection.test.ts +0 -47
  54. package/src/handlers/messaging-send-sweep-selection.ts +0 -35
  55. package/src/handlers/platform-fault-escalation/config.ts +0 -95
  56. package/src/handlers/platform-fault-escalation/delivery.ts +0 -327
  57. package/src/handlers/platform-fault-escalation/event-log-queries.ts +0 -182
  58. package/src/handlers/platform-fault-escalation/orpc-client.ts +0 -114
  59. package/src/handlers/platform-fault-escalation/probe.test.ts +0 -63
  60. package/src/handlers/platform-fault-escalation/probe.ts +0 -19
  61. package/src/handlers/platform-fault-escalation-logic.ts +0 -382
  62. package/src/handlers/platform-fault-escalation-refresh.test.ts +0 -72
  63. package/src/handlers/platform-fault-escalation.test.ts +0 -497
  64. package/src/handlers/platform-fault-escalation.ts +0 -246
  65. package/src/handlers/projects-digest.ts +0 -198
  66. package/src/handlers/quiz-research.ts +0 -178
  67. package/src/handlers/registry-stale-inactive-sweep.ts +0 -82
  68. package/src/handlers/scala-tools-currency-watch.test.ts +0 -65
  69. package/src/handlers/scala-tools-currency-watch.ts +0 -162
  70. package/src/handlers/singleton-tools-currency-watch.test.ts +0 -51
  71. package/src/handlers/singleton-tools-currency-watch.ts +0 -380
  72. package/src/handlers/sweep-event-log.ts +0 -98
  73. package/src/handlers/upstream-issue-sync.ts +0 -220
  74. package/src/handlers/whatsapp-liveness-handler.test.ts +0 -56
  75. package/src/handlers/whatsapp-liveness-logic.ts +0 -53
  76. package/src/handlers/whatsapp-liveness.test.ts +0 -78
  77. package/src/handlers/whatsapp-liveness.ts +0 -138
  78. package/src/interpolate-meta.ts +0 -123
  79. package/src/load-service-prisma-client.ts +0 -53
  80. package/src/loader-errors.ts +0 -41
  81. package/src/read-viewport-from-request.ts +0 -36
  82. package/src/register-page-server-client.test.ts +0 -185
  83. package/src/resolve-app-base-url.ts +0 -35
  84. package/src/resolve-from-loader-config.test.ts +0 -429
  85. package/src/resolve-from-loader-config.ts +0 -316
  86. package/src/resolve-service-context.ts +0 -88
@@ -1,83 +0,0 @@
1
- /**
2
- * @system server-boot
3
- * @status handwritten
4
- * @edit edit directly
5
- *
6
- * Pure reconciliation logic for the outbound-dispatch coverage half of
7
- * `messaging-send-recording-watch`: diff GOWA's outbound message list against
8
- * the recorded provider ids and shape the backfill rows — a send dispatched
9
- * outside every recording chokepoint exists only in GOWA's list (2026-08-26).
10
- */
11
-
12
- /** One outbound message from GOWA `getChatMessages` with is_from_me=true. */
13
- export interface GowaOutboundMessage {
14
- id: string;
15
- chat_jid: string;
16
- content: string | null;
17
- timestamp: string | null;
18
- is_from_me: boolean;
19
- }
20
-
21
- /** A GOWA-outbound message with no messaging_logs row — the backfill input. */
22
- export interface MissingSend {
23
- id: string;
24
- chatJid: string;
25
- content: string | null;
26
- timestamp: string;
27
- }
28
-
29
- /**
30
- * Diff GOWA's outbound truth against the recorded provider ids. Only
31
- * `is_from_me` messages count (the bridge device's own sends — inbound arrives
32
- * by webhook and records itself). Messages newer than the settle window are
33
- * held back one tick so the dispatch-time recorder's own write can land.
34
- */
35
- export function diffOutboundSends(
36
- messages: GowaOutboundMessage[],
37
- recordedIds: Set<string>,
38
- now: Date,
39
- settleMs: number,
40
- ): MissingSend[] {
41
- const missing: MissingSend[] = [];
42
- for (const message of messages) {
43
- if (!message.is_from_me) continue;
44
- if (!message.id || recordedIds.has(message.id)) continue;
45
- const at = Date.parse(message.timestamp ?? "");
46
- if (Number.isNaN(at)) continue;
47
- if (at > now.getTime() - settleMs) continue;
48
- missing.push({
49
- id: message.id,
50
- chatJid: message.chat_jid,
51
- content: message.content,
52
- timestamp: new Date(at).toISOString(),
53
- });
54
- }
55
- return missing;
56
- }
57
-
58
- /** The messaging_logs row that makes a bypassed send reconstructible. */
59
- export function buildBackfillRow(missing: MissingSend): {
60
- id: string;
61
- platform: "whatsapp";
62
- direction: "outbound";
63
- event_type: "sent";
64
- external_chat_id: string;
65
- user_phone: string;
66
- external_msg_id: string;
67
- content: string | null;
68
- payload: Record<string, unknown>;
69
- timestampIso: string;
70
- } {
71
- return {
72
- id: crypto.randomUUID(),
73
- platform: "whatsapp",
74
- direction: "outbound",
75
- event_type: "sent",
76
- external_chat_id: missing.chatJid,
77
- user_phone: missing.chatJid,
78
- external_msg_id: missing.id,
79
- content: missing.content,
80
- payload: { send_kind: "gowa-reconcile" },
81
- timestampIso: missing.timestamp,
82
- };
83
- }
@@ -1,86 +0,0 @@
1
- // @system codegen
2
- // @status generated
3
- // @edit change the suite in the owned-suites band, then re-run codegen. Hand-edits are overwritten.
4
- //
5
- // This suite's assertions are OWNED by the codegen band: the band module
6
- // carries them verbatim, this file is the emission, and hand edits here are
7
- // overwritten on the next run. The rationale each assertion carries moved
8
- // with it into the band.
9
-
10
- import { describe, expect, test } from "bun:test";
11
- import {
12
- buildUnrecordedPayload,
13
- detectUnrecordedSends,
14
- type UnrecordedSendRow,
15
- } from "./messaging-send-recording-logic.ts";
16
-
17
- const T = (iso: string) => new Date(iso);
18
-
19
- function row(id: string, iso: string, sendKind: string | null = null): UnrecordedSendRow {
20
- return { id, created_at: T(iso), external_chat_id: "447@s.whatsapp.net", send_kind: sendKind };
21
- }
22
-
23
- describe("detectUnrecordedSends", () => {
24
- test("zero rows is healthy — no finding, no event", () => {
25
- expect(detectUnrecordedSends([], 5)).toBeNull();
26
- });
27
-
28
- test("one row yields the finding with matching bounds", () => {
29
- const finding = detectUnrecordedSends([row("a", "2026-08-20T05:54:37.715Z")], 5);
30
- expect(finding).not.toBeNull();
31
- expect(finding!.unrecordedCount).toBe(1);
32
- expect(finding!.firstAt).toEqual(T("2026-08-20T05:54:37.715Z"));
33
- expect(finding!.lastAt).toEqual(T("2026-08-20T05:54:37.715Z"));
34
- });
35
-
36
- test("unordered rows are ordered into first/last bounds", () => {
37
- // The live pre-fix rows, newest first as a LIMIT-descending read might return.
38
- const finding = detectUnrecordedSends(
39
- [
40
- row("c", "2026-08-20T08:37:15.667Z"),
41
- row("a", "2026-08-20T05:54:37.715Z"),
42
- row("b", "2026-08-20T08:36:49.149Z"),
43
- ],
44
- 5,
45
- );
46
- expect(finding!.unrecordedCount).toBe(3);
47
- expect(finding!.firstAt).toEqual(T("2026-08-20T05:54:37.715Z"));
48
- expect(finding!.lastAt).toEqual(T("2026-08-20T08:37:15.667Z"));
49
- });
50
-
51
- test("the sample keeps the MOST RECENT rows up to the limit", () => {
52
- const rows = [
53
- row("a", "2026-08-20T05:54:37.715Z"),
54
- row("b", "2026-08-20T08:36:49.149Z"),
55
- row("c", "2026-08-20T08:37:15.667Z"),
56
- ];
57
- const finding = detectUnrecordedSends(rows, 2);
58
- expect(finding!.sample.map((r) => r.id)).toEqual(["b", "c"]);
59
- // The count still reports every row, not just the sample.
60
- expect(finding!.unrecordedCount).toBe(3);
61
- });
62
- });
63
-
64
- describe("buildUnrecordedPayload", () => {
65
- test("carries count, baseline, bounds, and ISO-string sample rows", () => {
66
- const finding = detectUnrecordedSends(
67
- [row("a", "2026-08-20T08:36:49.149Z", "relay-notify")],
68
- 5,
69
- )!;
70
- const payload = buildUnrecordedPayload(finding, "2026-08-20T10:19:00Z");
71
- expect(payload).toEqual({
72
- unrecordedCount: 1,
73
- recordedSince: "2026-08-20T10:19:00Z",
74
- firstAt: "2026-08-20T08:36:49.149Z",
75
- lastAt: "2026-08-20T08:36:49.149Z",
76
- sample: [
77
- {
78
- id: "a",
79
- createdAt: "2026-08-20T08:36:49.149Z",
80
- chatId: "447@s.whatsapp.net",
81
- sendKind: "relay-notify",
82
- },
83
- ],
84
- });
85
- });
86
- });
@@ -1,68 +0,0 @@
1
- /**
2
- * @system server-boot
3
- * @status handwritten
4
- * @edit edit directly
5
- *
6
- * Pure detection logic for the messaging send-recording watch
7
- * (`messaging-send-recording-watch` handler). Split from the DB-coupled handler
8
- * so the row-shape decision is unit-testable with zero database dependencies,
9
- * the same shape as whatsapp-liveness-logic.
10
- */
11
-
12
- /** One outbound messaging_logs row that never recorded its provider message id. */
13
- export interface UnrecordedSendRow {
14
- id: string;
15
- created_at: Date;
16
- external_chat_id: string | null;
17
- send_kind: string | null;
18
- }
19
-
20
- /** The aggregate the handler emits while the failure shape persists. */
21
- export interface UnrecordedSendFinding {
22
- unrecordedCount: number;
23
- firstAt: Date;
24
- lastAt: Date;
25
- sample: UnrecordedSendRow[];
26
- }
27
-
28
- /**
29
- * Classify freshly-read unrecorded outbound rows. Zero rows is the healthy
30
- * state (every send recorded its provider id) and yields null — no event,
31
- * idle-costs-nothing. Any row at/after the baseline is the failure signature
32
- * handed over 2026-08-20: a send whose provider id was never recorded can
33
- * never correlate its delivery receipt, so "did it reach the handset" is
34
- * unanswerable for exactly those sends.
35
- */
36
- export function detectUnrecordedSends(
37
- rows: UnrecordedSendRow[],
38
- sampleLimit: number,
39
- ): UnrecordedSendFinding | null {
40
- if (rows.length === 0) return null;
41
- const sorted = [...rows].sort((a, b) => a.created_at.getTime() - b.created_at.getTime());
42
- return {
43
- unrecordedCount: rows.length,
44
- firstAt: sorted[0]!.created_at,
45
- lastAt: sorted[sorted.length - 1]!.created_at,
46
- // The most recent rows are the ones a responder acts on first.
47
- sample: sorted.slice(-sampleLimit),
48
- };
49
- }
50
-
51
- /** The durable-event payload — ISO strings + the baseline it was read against. */
52
- export function buildUnrecordedPayload(
53
- finding: UnrecordedSendFinding,
54
- recordedSince: string,
55
- ): Record<string, unknown> {
56
- return {
57
- unrecordedCount: finding.unrecordedCount,
58
- recordedSince,
59
- firstAt: finding.firstAt.toISOString(),
60
- lastAt: finding.lastAt.toISOString(),
61
- sample: finding.sample.map((row) => ({
62
- id: row.id,
63
- createdAt: row.created_at.toISOString(),
64
- chatId: row.external_chat_id,
65
- sendKind: row.send_kind,
66
- })),
67
- };
68
- }
@@ -1,91 +0,0 @@
1
- // @system codegen
2
- // @status generated
3
- // @edit change the suite in the owned-suites band, then re-run codegen. Hand-edits are overwritten.
4
- //
5
- // This suite's assertions are OWNED by the codegen band: the band module
6
- // carries them verbatim, this file is the emission, and hand edits here are
7
- // overwritten on the next run. The rationale each assertion carries moved
8
- // with it into the band.
9
-
10
- import { beforeEach, expect, mock, test } from "bun:test";
11
-
12
- const recordEventCalls: Array<{ kind: string; payload: Record<string, unknown> }> = [];
13
-
14
- // The live unrecorded rows the query returns — mutated per test.
15
- let mockRows: Array<{
16
- id: string;
17
- created_at: Date;
18
- external_chat_id: string | null;
19
- send_kind: string | null;
20
- }>;
21
- let queryArgs: unknown[] = [];
22
-
23
- mock.module("@teamscala/db/prisma-registry", () => ({
24
- getPrisma: () => ({
25
- $queryRawUnsafe: async (_sql: string, ...args: unknown[]) => {
26
- queryArgs = args;
27
- return mockRows;
28
- },
29
- }),
30
- }));
31
- mock.module("@teamscala/event-log/record-event", () => ({
32
- recordEvent: (e: { kind: string; payload: Record<string, unknown> }) => {
33
- recordEventCalls.push(e);
34
- },
35
- }));
36
- // Self-contained in any suite order: another handler test file installs a
37
- // partial process-global app-loggers mock that leaks into this one, so this
38
- // file pins the logger shape it needs rather than depending on run order.
39
- mock.module("@teamscala/logger/app-loggers", () => ({
40
- getAppLogger: () => ({
41
- info: () => {},
42
- warn: () => {},
43
- error: () => {},
44
- }),
45
- }));
46
-
47
- const { messagingSendRecordingWatch } = await import("./messaging-send-recording-watch.ts");
48
-
49
- const CONFIG = {
50
- recorded_since: "2026-08-01T00:00:00Z",
51
- sample_limit: 2,
52
- } as never;
53
-
54
- beforeEach(() => {
55
- recordEventCalls.length = 0;
56
- queryArgs = [];
57
- });
58
-
59
- test("unrecorded rows at/after the baseline emit one messaging.send.unrecorded event", async () => {
60
- mockRows = [
61
- { id: "a", created_at: new Date("2026-08-20T05:54:37.715Z"), external_chat_id: "447@s.whatsapp.net", send_kind: null },
62
- { id: "b", created_at: new Date("2026-08-20T08:36:49.149Z"), external_chat_id: "447@s.whatsapp.net", send_kind: null },
63
- { id: "c", created_at: new Date("2026-08-20T08:37:15.667Z"), external_chat_id: "447@s.whatsapp.net", send_kind: null },
64
- ];
65
- await messagingSendRecordingWatch({ config: CONFIG });
66
-
67
- expect(recordEventCalls).toHaveLength(1);
68
- expect(recordEventCalls[0]!.kind).toBe("messaging.send.unrecorded");
69
- expect(recordEventCalls[0]!.payload).toMatchObject({
70
- unrecordedCount: 3,
71
- recordedSince: "2026-08-01T00:00:00Z",
72
- });
73
- // The query reads the baseline as a bound — the predicate's timestamp is
74
- // part of the failure signature, not decoration.
75
- expect(queryArgs).toEqual(["2026-08-01T00:00:00Z"]);
76
- });
77
-
78
- test("healthy shape (every send recorded) emits nothing", async () => {
79
- mockRows = [];
80
- await messagingSendRecordingWatch({ config: CONFIG });
81
- expect(recordEventCalls).toEqual([]);
82
- });
83
-
84
- test("missing recorded_since baseline skips the query rather than sweeping pre-fix history", async () => {
85
- mockRows = [
86
- { id: "historic", created_at: new Date("2026-08-01T00:00:00Z"), external_chat_id: null, send_kind: null },
87
- ];
88
- await messagingSendRecordingWatch({ config: {} as never });
89
- expect(recordEventCalls).toEqual([]);
90
- expect(queryArgs).toEqual([]);
91
- });
@@ -1,244 +0,0 @@
1
- /**
2
- * @system server-boot
3
- * @status handwritten
4
- * @edit edit directly
5
- *
6
- * The `messaging-send-recording-watch` pg-boss handler — watches the durable
7
- * row shape that makes delivery acks correlatable: every outbound
8
- * messaging_logs row created at/after `recorded_since` must carry the provider
9
- * message id (`external_msg_id`). A row without it is a send whose
10
- * `message.ack` receipt can never correlate — the regression fixed
11
- * 2026-08-20T10:19Z (the single send path in messaging-service outbound.rs),
12
- * whose silent return is otherwise invisible: the ack-time "cannot correlate"
13
- * warning fires only when a live webhook consumer receives an ack, so a dead
14
- * consumer or a recording break both read as healthy silence. This watch
15
- * reads the shared platform database directly — independent of the webhook
16
- * consumer, GOWA, and any scale-to-zero surface — and emits one
17
- * `messaging.send.unrecorded` durable event per tick while the shape
18
- * persists. Thresholding (duration + recurrence), episode dedup, and operator
19
- * delivery belong to the platform-fault escalator, not here.
20
- *
21
- * Registered under `@teamscala/service-boot:messaging-send-recording-watch`.
22
- */
23
- import type { ScheduledJobConfig } from "@teamscala/db-validation/registry-schemas/scheduled-job";
24
- import { getPrisma } from "@teamscala/db/prisma-registry";
25
- import { recordEvent } from "@teamscala/event-log/record-event";
26
- import { getAppLogger } from "@teamscala/logger/app-loggers";
27
- import { pgBossHandlerRegistry } from "../pg-boss-handler-registry.ts";
28
- import {
29
- buildUnrecordedPayload,
30
- detectUnrecordedSends,
31
- type UnrecordedSendRow,
32
- } from "./messaging-send-recording-logic.ts";
33
- import {
34
- buildBackfillRow,
35
- diffOutboundSends,
36
- type GowaOutboundMessage,
37
- } from "./messaging-send-reconcile-logic.ts";
38
- import {
39
- selectChatsToSweep,
40
- type GowaChatSummary,
41
- } from "./messaging-send-sweep-selection.ts";
42
- import { getSystemIdentity } from "./whatsapp-liveness.ts";
43
-
44
- const DEFAULT_SAMPLE_LIMIT = 5;
45
- /** Bounds the per-tick read while a break runs for weeks; far above daily volume. */
46
- const QUERY_ROW_CAP = 1000;
47
- /** GOWA page size cap (the spec's maximum for both listChats and messages). */
48
- const GOWA_PAGE_LIMIT = 100;
49
- /** Upper bound on message pages walked per chat per tick. */
50
- const MAX_MESSAGE_PAGES_PER_CHAT = 5;
51
- /** A freshly-dispatched send waits this long before the reconcile sweep may
52
- * claim it — the tool-dispatch recorder writes its row at dispatch, and
53
- * sweeping sooner would backfill a duplicate of a row about to exist. */
54
- const RECONCILE_SETTLE_MS = 120_000;
55
-
56
- export async function messagingSendRecordingWatch({
57
- config,
58
- }: {
59
- config: ScheduledJobConfig;
60
- }): Promise<void> {
61
- const log = getAppLogger();
62
-
63
- // Fail explicit, not historic: without a baseline the query would sweep
64
- // pre-fix history and alert on rows that were never promised an id.
65
- const recordedSince = config.recorded_since;
66
- if (!recordedSince) {
67
- log.error(
68
- "[messaging-send-recording-watch] scheduled_job row has no recorded_since baseline — skipping (a baseline-less watch would fire on pre-fix history)",
69
- );
70
- return;
71
- }
72
-
73
- const prisma = getPrisma();
74
- const rows = await prisma.$queryRawUnsafe<UnrecordedSendRow[]>(
75
- `SELECT id, created_at, external_chat_id, payload->>'send_kind' AS send_kind
76
- FROM messaging_logs
77
- WHERE direction = 'outbound'
78
- AND external_msg_id IS NULL
79
- AND created_at >= $1::timestamptz
80
- ORDER BY created_at ASC
81
- LIMIT ${QUERY_ROW_CAP}`,
82
- recordedSince,
83
- );
84
-
85
- const finding = detectUnrecordedSends(rows, config.sample_limit ?? DEFAULT_SAMPLE_LIMIT);
86
- if (!finding) {
87
- log.info(
88
- `[messaging-send-recording-watch] poll complete — every outbound send at/after ${recordedSince} recorded its provider id`,
89
- );
90
- } else {
91
- const payload = buildUnrecordedPayload(finding, recordedSince);
92
- recordEvent({ kind: "messaging.send.unrecorded", payload });
93
- log.warn(
94
- `[messaging-send-recording-watch] ${finding.unrecordedCount} outbound send(s) at/after ${recordedSince} recorded no provider message id — their delivery receipts can never correlate`,
95
- payload,
96
- );
97
- }
98
-
99
- // Dispatch-coverage reconcile — the row-shape check above can only inspect
100
- // rows that EXIST; a send dispatched to GOWA outside every recording
101
- // chokepoint (measured 2026-08-26: raw multipart sends delivered with zero
102
- // rows) is invisible to it. GOWA's own message list is the authoritative
103
- // outbound record, so this sweep backfills what bypassed us. Failure here
104
- // must never take the row-shape check down with it.
105
- try {
106
- const backfilled = await reconcileOutboundDispatches(recordedSince);
107
- if (backfilled > 0) {
108
- log.warn(
109
- `[messaging-send-recording-watch] reconcile: backfilled ${backfilled} outbound send(s) GOWA delivered with no messaging_logs row (dispatched outside the recording surface)`,
110
- );
111
- }
112
- } catch (error) {
113
- const message = error instanceof Error ? error.message : String(error);
114
- log.warn(`[messaging-send-recording-watch] reconcile sweep failed: ${message}`);
115
- }
116
- }
117
-
118
- /** Dispatch one api_gowa_chats action through the central /mcp loopback — the
119
- * same single sealed-credential surface whatsapp-liveness uses, as the system
120
- * caller. */
121
- async function dispatchGowaChats(
122
- action: string,
123
- params: Record<string, unknown>,
124
- ): Promise<unknown> {
125
- const { forwardToolToCentralMcp } = await import(
126
- "@teamscala/chat-completion/central-tool-forwarder"
127
- );
128
- const { userId, organisationId } = await getSystemIdentity();
129
- return forwardToolToCentralMcp(
130
- "api_gowa_chats",
131
- { action, params },
132
- { userId, organisationId } as Parameters<typeof forwardToolToCentralMcp>[2],
133
- );
134
- }
135
-
136
- interface GowaPaged<T> {
137
- results?: { data?: T[]; pagination?: { total?: number } };
138
- }
139
-
140
- /**
141
- * The convergent half of the watch: walk GOWA's chats newest-first for
142
- * outbound activity at/after the baseline, read each qualifying chat's own
143
- * sends, and backfill every provider id messaging_logs is missing. Idempotent
144
- * by external_msg_id — a backfilled row can never be backfilled twice, and the
145
- * settle window keeps it from racing the dispatch-time recorder.
146
- */
147
- async function reconcileOutboundDispatches(recordedSince: string): Promise<number> {
148
- const log = getAppLogger();
149
- const horizonMs = Date.parse(recordedSince);
150
- if (Number.isNaN(horizonMs)) return 0;
151
-
152
- const chatJids: string[] = [];
153
- for (let offset = 0; offset < 100 * 20; offset += GOWA_PAGE_LIMIT) {
154
- const page = (await dispatchGowaChats("listChats", {
155
- limit: GOWA_PAGE_LIMIT,
156
- offset,
157
- })) as GowaPaged<GowaChatSummary> | undefined;
158
- const chats = (page?.results?.data ?? []) as GowaChatSummary[];
159
- if (chats.length === 0) break;
160
- const { toSweep, mayStopPaging } = selectChatsToSweep(chats, horizonMs);
161
- chatJids.push(...toSweep);
162
- if (mayStopPaging) break;
163
- }
164
-
165
- const outbound: GowaOutboundMessage[] = [];
166
- for (const chatJid of chatJids) {
167
- for (let page = 0; page < MAX_MESSAGE_PAGES_PER_CHAT; page++) {
168
- const messages = (await dispatchGowaChats("getChatMessages", {
169
- chat_jid: chatJid,
170
- is_from_me: true,
171
- start_time: recordedSince,
172
- limit: GOWA_PAGE_LIMIT,
173
- offset: page * GOWA_PAGE_LIMIT,
174
- })) as GowaPaged<GowaOutboundMessage> | undefined;
175
- const batch = ((messages?.results?.data ?? []) as GowaOutboundMessage[]).filter(
176
- (m) => m && typeof m.id === "string",
177
- );
178
- outbound.push(...batch);
179
- if (batch.length < GOWA_PAGE_LIMIT) break;
180
- }
181
- }
182
- if (outbound.length === 0) return 0;
183
-
184
- const prisma = getPrisma();
185
- const ids = outbound.map((m) => m.id);
186
- const recorded = await prisma.$queryRawUnsafe<Array<{ external_msg_id: string }>>(
187
- `SELECT external_msg_id FROM messaging_logs WHERE external_msg_id = ANY($1::text[])`,
188
- ids,
189
- );
190
- const recordedIds = new Set(recorded.map((r) => r.external_msg_id));
191
- const missing = diffOutboundSends(outbound, recordedIds, new Date(), RECONCILE_SETTLE_MS);
192
- if (missing.length === 0) return 0;
193
-
194
- for (const miss of missing) {
195
- const row = buildBackfillRow(miss);
196
- // createMany + skipDuplicates: the table's unique (agent_id,
197
- // external_chat_id, external_msg_id) makes a double-backfill (a sweep
198
- // racing the dispatch-time recorder) a no-op instead of a crash.
199
- await prisma.messaging_logs.createMany({
200
- skipDuplicates: true,
201
- data: {
202
- id: row.id,
203
- organisation_id: "",
204
- platform: row.platform,
205
- agent_id: "",
206
- external_chat_id: row.external_chat_id,
207
- user_phone: row.user_phone,
208
- external_msg_id: row.external_msg_id,
209
- direction: row.direction,
210
- event_type: row.event_type,
211
- content: row.content,
212
- payload: {
213
- ...row.payload,
214
- send_at: row.timestampIso,
215
- reconciled_at: new Date().toISOString(),
216
- },
217
- created_at: new Date(row.timestampIso),
218
- },
219
- });
220
- }
221
-
222
- recordEvent({
223
- kind: "messaging.send.reconciled",
224
- payload: {
225
- backfilledCount: missing.length,
226
- baseline: recordedSince,
227
- sample: missing.slice(0, DEFAULT_SAMPLE_LIMIT).map((m) => ({
228
- external_msg_id: m.id,
229
- chat_id: m.chatJid,
230
- content: m.content?.slice(0, 120) ?? null,
231
- sent_at: m.timestamp,
232
- })),
233
- },
234
- });
235
- log.info(
236
- `[messaging-send-recording-watch] reconcile: ${missing.length} unrecorded outbound send(s) backfilled from GOWA truth`,
237
- );
238
- return missing.length;
239
- }
240
-
241
- pgBossHandlerRegistry.register(
242
- "@teamscala/service-boot:messaging-send-recording-watch",
243
- messagingSendRecordingWatch,
244
- );
@@ -1,47 +0,0 @@
1
- // @system codegen
2
- // @status generated
3
- // @edit change the suite in the owned-suites band, then re-run codegen. Hand-edits are overwritten.
4
- //
5
- // This suite's assertions are OWNED by the codegen band: the band module
6
- // carries them verbatim, this file is the emission, and hand edits here are
7
- // overwritten on the next run. The rationale each assertion carries moved
8
- // with it into the band.
9
-
10
- import { describe, expect, test } from "bun:test";
11
- import {
12
- selectChatsToSweep,
13
- type GowaChatSummary,
14
- } from "./messaging-send-sweep-selection.ts";
15
-
16
- describe("selectChatsToSweep", () => {
17
- test("keeps chats at/after the horizon and lets paging stop once a page is older", () => {
18
- const chats: GowaChatSummary[] = [
19
- { jid: "a@s.whatsapp.net", last_message_time: "2026-08-26T02:14:00Z" },
20
- { jid: "b@s.whatsapp.net", last_message_time: "2026-08-25T16:14:13Z" },
21
- { jid: "old@s.whatsapp.net", last_message_time: "2026-08-01T00:00:00Z" },
22
- ];
23
- const { toSweep, mayStopPaging } = selectChatsToSweep(
24
- chats,
25
- Date.parse("2026-08-25T10:19:00Z"),
26
- );
27
- expect(toSweep).toEqual(["a@s.whatsapp.net", "b@s.whatsapp.net"]);
28
- expect(mayStopPaging).toBe(true);
29
- });
30
-
31
- test("keeps paging while the page's newest chats still qualify", () => {
32
- const chats: GowaChatSummary[] = [
33
- { jid: "a@s.whatsapp.net", last_message_time: "2026-08-26T02:14:00Z" },
34
- ];
35
- const { mayStopPaging } = selectChatsToSweep(chats, Date.parse("2026-08-25T10:19:00Z"));
36
- expect(mayStopPaging).toBe(false);
37
- });
38
-
39
- test("a chat with no last_message_time never qualifies and stops paging", () => {
40
- const { toSweep, mayStopPaging } = selectChatsToSweep(
41
- [{ jid: "silent@s.whatsapp.net", last_message_time: null }],
42
- 0,
43
- );
44
- expect(toSweep).toEqual([]);
45
- expect(mayStopPaging).toBe(true);
46
- });
47
- });
@@ -1,35 +0,0 @@
1
- /**
2
- * @system server-boot
3
- * @status handwritten
4
- * @edit edit directly
5
- *
6
- * Chat-page selection for the messaging-send reconcile sweep: which paged
7
- * GOWA chats (newest-first) still have activity at/after the baseline, and
8
- * whether paging may stop. Pure and dependency-free, the same shape as the
9
- * sibling *-logic units.
10
- */
11
-
12
- /** One chat from GOWA `listChats` — only the sweep-relevant fields. */
13
- export interface GowaChatSummary {
14
- jid: string;
15
- last_message_time: string | null;
16
- }
17
-
18
- /** Chats worth sweeping: those with activity at/after the horizon. */
19
- export function selectChatsToSweep(
20
- chats: GowaChatSummary[],
21
- horizonMs: number,
22
- ): { toSweep: string[]; mayStopPaging: boolean } {
23
- const toSweep: string[] = [];
24
- for (const chat of chats) {
25
- const at = Date.parse(chat.last_message_time ?? "");
26
- if (!Number.isNaN(at) && at >= horizonMs) toSweep.push(chat.jid);
27
- }
28
- const last = chats[chats.length - 1];
29
- // A null/unparseable last_message_time means the chat never messaged (or
30
- // GOWA answered something unexpected) — it cannot hide an outbound send, so
31
- // it reads as "older than the horizon" for the paging decision.
32
- const lastAt = Date.parse(last?.last_message_time ?? "");
33
- const mayStopPaging = chats.length === 0 || Number.isNaN(lastAt) || lastAt < horizonMs;
34
- return { toSweep, mayStopPaging };
35
- }