@teamscala/service-boot 0.23.284
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/package.json +1016 -0
- package/src/agreement-gate.test.ts +118 -0
- package/src/agreement-gate.ts +887 -0
- package/src/asset-cache-key.ts +59 -0
- package/src/asset-optimize.ts +93 -0
- package/src/asset-proxy.ts +148 -0
- package/src/asset-social-card.ts +58 -0
- package/src/attach-agreement-magic.ts +62 -0
- package/src/attach-portal-members.ts +107 -0
- package/src/attach-proposal-accept.ts +123 -0
- package/src/attach-proposal-magic.ts +55 -0
- package/src/attach-transfers-upload.ts +100 -0
- package/src/auth-gate.ts +385 -0
- package/src/bind-page-handler.ts +223 -0
- package/src/build-client-assets/input-hashing.ts +206 -0
- package/src/build-client-assets/source-candidates.ts +115 -0
- package/src/build-client-assets.test.ts +216 -0
- package/src/build-client-assets.ts +962 -0
- package/src/compose-page-title.test.ts +88 -0
- package/src/compose-page-title.ts +73 -0
- package/src/content-gate/access.ts +89 -0
- package/src/content-gate/config-types.ts +108 -0
- package/src/content-gate/gate.ts +74 -0
- package/src/content-gate/parse-cookie.ts +23 -0
- package/src/content-gate/token.test.ts +70 -0
- package/src/content-gate/token.ts +148 -0
- package/src/content-page-floor.test.ts +127 -0
- package/src/content-page-floor.ts +70 -0
- package/src/discover-blocks.ts +228 -0
- package/src/expand-roster.ts +197 -0
- package/src/fn-routers/agreement-completion-email.ts +115 -0
- package/src/fn-routers/agreement-send-router.ts +303 -0
- package/src/fn-routers/ai-health-router.ts +64 -0
- package/src/fn-routers/auth-router.ts +131 -0
- package/src/fn-routers/cache-admin-router.ts +50 -0
- package/src/fn-routers/chat-session-router.ts +455 -0
- package/src/fn-routers/email-defaults.ts +22 -0
- package/src/fn-routers/email-link-ttl.ts +23 -0
- package/src/fn-routers/index.ts +70 -0
- package/src/fn-routers/panel-data-router.ts +93 -0
- package/src/fn-routers/persist-chat-messages.test.ts +86 -0
- package/src/fn-routers/persist-chat-messages.ts +96 -0
- package/src/fn-routers/plans-router.ts +141 -0
- package/src/fn-routers/proposal-send-router.ts +207 -0
- package/src/fn-routers/public-agreement-router.ts +865 -0
- package/src/fn-routers/public-assessment-router.ts +421 -0
- package/src/fn-routers/public-gpt-session-router.ts +373 -0
- package/src/fn-routers/sign-in-flow-router.ts +196 -0
- package/src/fn-routers/task-sync-bind.test.ts +105 -0
- package/src/fn-routers/task-sync-classify.test.ts +109 -0
- package/src/fn-routers/task-sync-dispatch.test.ts +44 -0
- package/src/fn-routers/task-sync-dispatch.ts +124 -0
- package/src/fn-routers/task-sync-router.test.ts +279 -0
- package/src/fn-routers/task-sync-router.ts +1036 -0
- package/src/fn-routers/tasks-router.ts +77 -0
- package/src/fn-routers/user-preferences-router.ts +139 -0
- package/src/fn-routers/work-items-router.ts +341 -0
- package/src/gate-ttls.ts +56 -0
- package/src/gpt-gate.ts +76 -0
- package/src/handlers/bun-stable-watch.ts +89 -0
- package/src/handlers/projects-digest.ts +198 -0
- package/src/handlers/prune-stale-data.ts +110 -0
- package/src/handlers/registry-stale-inactive-sweep.ts +82 -0
- package/src/handlers/scala-tools-currency-watch.test.ts +60 -0
- package/src/handlers/scala-tools-currency-watch.ts +171 -0
- package/src/handlers/scheduled-browser-tests.ts +85 -0
- package/src/handlers/sweep-event-log.ts +98 -0
- package/src/handlers/sweep-host-cache.ts +211 -0
- package/src/handlers/upstream-issue-sync.ts +210 -0
- package/src/handlers/whatsapp-liveness-handler.test.ts +59 -0
- package/src/handlers/whatsapp-liveness-logic.ts +53 -0
- package/src/handlers/whatsapp-liveness.test.ts +78 -0
- package/src/handlers/whatsapp-liveness.ts +125 -0
- package/src/hono-error-handler.ts +55 -0
- package/src/install-harness.ts +80 -0
- package/src/install-logging.ts +182 -0
- package/src/load-service-install-row.ts +98 -0
- package/src/load-service-prisma-client.ts +53 -0
- package/src/mount-orpc-router.ts +148 -0
- package/src/mount-server-packages.ts +155 -0
- package/src/mount.ts +18 -0
- package/src/mutable-app-ref.ts +68 -0
- package/src/page-route-shell-types.ts +16 -0
- package/src/page-server-orpc/audit-revert-router.ts +102 -0
- package/src/page-server-orpc/hub-mcp-client.ts +20 -0
- package/src/page-server-orpc/portal-diagnostics-router.ts +170 -0
- package/src/page-server-orpc/puck-pages-router.ts +272 -0
- package/src/page-server-orpc/server-client.ts +31 -0
- package/src/page-server-orpc/templates-router.ts +116 -0
- package/src/page-server-orpc/user-preferences-router.ts +132 -0
- package/src/pg-boss-handler-registry.ts +36 -0
- package/src/pg-boss-mount.ts +295 -0
- package/src/proposal-authorized-emails.ts +65 -0
- package/src/proposal-chat-inject.ts +141 -0
- package/src/proposal-gate.ts +175 -0
- package/src/read-html-template.test.ts +74 -0
- package/src/read-html-template.ts +102 -0
- package/src/read-viewport-from-request.ts +36 -0
- package/src/readiness-gate.ts +39 -0
- package/src/register-api-routes.ts +127 -0
- package/src/register-crash-handlers.ts +39 -0
- package/src/register-page-routes.ts +62 -0
- package/src/register-page-server-client.ts +109 -0
- package/src/register-registry-page-route/ai-chat-prefetch.ts +106 -0
- package/src/register-registry-page-route/graph-cache.test.ts +84 -0
- package/src/register-registry-page-route/graph-cache.ts +90 -0
- package/src/register-registry-page-route/meta-title.ts +73 -0
- package/src/register-registry-page-route/proposal-approval-email-token.ts +78 -0
- package/src/register-registry-page-route/proposal-brand-stamping.ts +60 -0
- package/src/register-registry-page-route/resolve-page-data.ts +518 -0
- package/src/register-registry-page-route.ts +765 -0
- package/src/register-static-assets.ts +180 -0
- package/src/register-ws-routes.ts +180 -0
- package/src/resolve-app-base-url.ts +28 -0
- package/src/resolve-brand-favicon.ts +65 -0
- package/src/resolve-from-loader-config.test.ts +44 -0
- package/src/resolve-from-loader-config.ts +291 -0
- package/src/resolve-service-context.ts +67 -0
- package/src/resolve-theme-mode.ts +61 -0
- package/src/resolvers/fetch-org-brand.ts +28 -0
- package/src/resolvers/portal-route-paths.test.ts +102 -0
- package/src/resolvers/portal-route-paths.ts +83 -0
- package/src/resolvers/website-by-host-map.ts +49 -0
- package/src/resource-chat-inject.ts +168 -0
- package/src/resource-not-found.ts +263 -0
- package/src/run-boot-hook-list.ts +75 -0
- package/src/run-boot-hooks.ts +97 -0
- package/src/scala-tools-currency-watchdog.ts +160 -0
- package/src/security-headers.ts +59 -0
- package/src/serve/serve-ws.test.ts +135 -0
- package/src/serve/serve.ts +190 -0
- package/src/server-error-page.test.ts +102 -0
- package/src/server-error-page.ts +95 -0
- package/src/start-scheduled-jobs.ts +84 -0
- package/src/two-phase-boot.ts +449 -0
- package/src/types.ts +52 -0
- package/src/ui-service.ts +171 -0
- package/src/ws-boot-self-test.ts +114 -0
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @system server-boot
|
|
3
|
+
* @status handwritten
|
|
4
|
+
* @edit edit directly
|
|
5
|
+
*
|
|
6
|
+
* The `upstream-issue-sync` pg-boss handler (doctrine `upstream-issues-tracked-in-
|
|
7
|
+
* registry`). For each active `upstream_issue` registry row it polls the row's
|
|
8
|
+
* GitHub issue + comments and writes back `state` / `comment_count` /
|
|
9
|
+
* `last_comment_at` / `latest_response` / `last_synced_at`, emitting
|
|
10
|
+
* `recordEvent({ kind: "upstream.issue.response" })` when a NEW maintainer
|
|
11
|
+
* (non-filer) comment appears — so a maintainer response on an issue we filed
|
|
12
|
+
* never goes unnoticed.
|
|
13
|
+
*
|
|
14
|
+
* Polls via the authenticated `gh` CLI (`@teamscala/os` spawn — `gh api` is the
|
|
15
|
+
* off-the-shelf GitHub client; NOT a bespoke fetch). The intended
|
|
16
|
+
* `@teamscala/api-github` `rest_client` adapter is deferred on the codegen-harness
|
|
17
|
+
* breakage (open migration `rest-client-codegen-typescript-7-broken`); this
|
|
18
|
+
* handler migrates to it once the harness is fixed.
|
|
19
|
+
*
|
|
20
|
+
* Registered under slug `@teamscala/service-boot:upstream-issue-sync`. Mirrors
|
|
21
|
+
* `bun-stable-watch` (dev-host pg-boss handler). Reads rows via the `getAllEntries`
|
|
22
|
+
* helper and writes back via `persistRegistryEntryConfig` (the validated
|
|
23
|
+
* registry_entries writer — `registry-via-helper` blocks direct registry Prisma
|
|
24
|
+
* in package code).
|
|
25
|
+
*/
|
|
26
|
+
import { getAllEntries } from "@teamscala/db/registry/accessors";
|
|
27
|
+
import { persistRegistryEntryConfig } from "@teamscala/db/registry/persist-entry-config";
|
|
28
|
+
import type { ScheduledJobConfig } from "@teamscala/db-validation/registry-schemas/scheduled-job";
|
|
29
|
+
import { recordEvent } from "@teamscala/event-log/record-event";
|
|
30
|
+
import { getAppLogger } from "@teamscala/logger/app-loggers";
|
|
31
|
+
import { spawn } from "@teamscala/os/spawn";
|
|
32
|
+
import { loadRegistryConfig } from "@teamscala/db/registry/load-config";
|
|
33
|
+
import * as v from "valibot";
|
|
34
|
+
import { pgBossHandlerRegistry } from "../pg-boss-handler-registry.ts";
|
|
35
|
+
|
|
36
|
+
interface GhIssue {
|
|
37
|
+
state: string;
|
|
38
|
+
comments: number;
|
|
39
|
+
author: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface GhComment {
|
|
43
|
+
author: string;
|
|
44
|
+
at: string;
|
|
45
|
+
body: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
interface CapturedResponse {
|
|
49
|
+
author: string;
|
|
50
|
+
excerpt: string;
|
|
51
|
+
at: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const DEFAULT_GH_TIMEOUT_MS = 20_000;
|
|
55
|
+
let cachedGhTimeoutMs: number | undefined;
|
|
56
|
+
async function getGhTimeoutMs(): Promise<number> {
|
|
57
|
+
if (cachedGhTimeoutMs !== undefined) return cachedGhTimeoutMs;
|
|
58
|
+
const row = await loadRegistryConfig("config", "upstream-issue-sync", v.looseObject({}));
|
|
59
|
+
cachedGhTimeoutMs = (row?.gh_timeout_ms as number | undefined) ?? DEFAULT_GH_TIMEOUT_MS;
|
|
60
|
+
return cachedGhTimeoutMs;
|
|
61
|
+
}
|
|
62
|
+
const EXCERPT_MAX = 160;
|
|
63
|
+
|
|
64
|
+
/** Fetch a GitHub issue's state/comment-count/author via `gh api`. */
|
|
65
|
+
async function ghIssue(owner: string, repo: string, number: number): Promise<GhIssue | null> {
|
|
66
|
+
const { stdout, exitCode, stderr } = await spawn({
|
|
67
|
+
name: "upstream-issue-sync:gh-issue",
|
|
68
|
+
command: [
|
|
69
|
+
"gh",
|
|
70
|
+
"api",
|
|
71
|
+
`repos/${owner}/${repo}/issues/${number}`,
|
|
72
|
+
"--jq",
|
|
73
|
+
"{state: .state, comments: .comments, author: .user.login}",
|
|
74
|
+
],
|
|
75
|
+
timeoutMs: await getGhTimeoutMs(),
|
|
76
|
+
});
|
|
77
|
+
if (exitCode !== 0) {
|
|
78
|
+
getAppLogger().warn(
|
|
79
|
+
`[upstream-issue-sync] gh api issue ${owner}/${repo}#${number} failed: ${stderr.trim() || stdout.trim()}`,
|
|
80
|
+
);
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
try {
|
|
84
|
+
return JSON.parse(stdout) as GhIssue;
|
|
85
|
+
} catch {
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Fetch a GitHub issue's comments via `gh api` (per_page=100). */
|
|
91
|
+
async function ghComments(owner: string, repo: string, number: number): Promise<GhComment[]> {
|
|
92
|
+
const { stdout, exitCode } = await spawn({
|
|
93
|
+
name: "upstream-issue-sync:gh-comments",
|
|
94
|
+
command: [
|
|
95
|
+
"gh",
|
|
96
|
+
"api",
|
|
97
|
+
`repos/${owner}/${repo}/issues/${number}/comments?per_page=100`,
|
|
98
|
+
"--jq",
|
|
99
|
+
"[.[] | {author: .user.login, at: .created_at, body: .body}]",
|
|
100
|
+
],
|
|
101
|
+
timeoutMs: await getGhTimeoutMs(),
|
|
102
|
+
});
|
|
103
|
+
if (exitCode !== 0) return [];
|
|
104
|
+
try {
|
|
105
|
+
const parsed = JSON.parse(stdout) as GhComment[];
|
|
106
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
107
|
+
} catch {
|
|
108
|
+
return [];
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function excerpt(body: string): string {
|
|
113
|
+
const flat = body.replace(/\s+/g, " ").trim();
|
|
114
|
+
return flat.length > EXCERPT_MAX ? `${flat.slice(0, EXCERPT_MAX)}…` : flat;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* The newest comment NOT by the issue filer (a maintainer response), or null.
|
|
119
|
+
* GitHub returns comments oldest-first; sort by `at` to be safe.
|
|
120
|
+
*/
|
|
121
|
+
function newestMaintainerResponse(
|
|
122
|
+
comments: GhComment[],
|
|
123
|
+
filer: string,
|
|
124
|
+
): CapturedResponse | null {
|
|
125
|
+
const external = comments
|
|
126
|
+
.filter((c) => c.author !== filer)
|
|
127
|
+
.sort((a, b) => (a.at < b.at ? 1 : a.at > b.at ? -1 : 0));
|
|
128
|
+
const newest = external[0];
|
|
129
|
+
if (!newest) return null;
|
|
130
|
+
return { author: newest.author, excerpt: excerpt(newest.body), at: newest.at };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async function upstreamIssueSync(_ctx: { config: ScheduledJobConfig }): Promise<void> {
|
|
134
|
+
const log = getAppLogger();
|
|
135
|
+
|
|
136
|
+
const rows = getAllEntries().filter(
|
|
137
|
+
(e) => (e.type as string) === "upstream_issue" && e.is_active,
|
|
138
|
+
);
|
|
139
|
+
if (rows.length === 0) {
|
|
140
|
+
log.info("[upstream-issue-sync] no active upstream_issue rows");
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
let updated = 0;
|
|
145
|
+
for (const row of rows) {
|
|
146
|
+
const cfg = row.config as Record<string, unknown>;
|
|
147
|
+
const repo = String(cfg.repo ?? "");
|
|
148
|
+
const issueNumber = Number(cfg.issue_number);
|
|
149
|
+
const [owner, repoName] = repo.split("/");
|
|
150
|
+
if (!owner || !repoName || !Number.isInteger(issueNumber)) {
|
|
151
|
+
log.warn(`[upstream-issue-sync] row ${row.slug} has invalid repo/issue_number`);
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const issue = await ghIssue(owner, repoName, issueNumber);
|
|
156
|
+
if (!issue) continue; // gh failure already logged; skip this row this cycle
|
|
157
|
+
const comments = await ghComments(owner, repoName, issueNumber);
|
|
158
|
+
const lastCommentAt =
|
|
159
|
+
comments.length > 0 ? (comments.map((c) => c.at).sort().at(-1) ?? null) : null;
|
|
160
|
+
const latestResponse = newestMaintainerResponse(comments, issue.author);
|
|
161
|
+
|
|
162
|
+
const prevResponseAt = (
|
|
163
|
+
cfg.latest_response as { at?: string } | null | undefined
|
|
164
|
+
)?.at ?? null;
|
|
165
|
+
|
|
166
|
+
const nowIso = new Date().toISOString();
|
|
167
|
+
const nextConfig = {
|
|
168
|
+
...cfg,
|
|
169
|
+
state: issue.state === "closed" ? "closed" : "open",
|
|
170
|
+
comment_count: issue.comments,
|
|
171
|
+
last_comment_at: lastCommentAt,
|
|
172
|
+
latest_response: latestResponse,
|
|
173
|
+
last_synced_at: nowIso,
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
try {
|
|
177
|
+
await persistRegistryEntryConfig("upstream_issue", row.slug, nextConfig);
|
|
178
|
+
} catch (err) {
|
|
179
|
+
log.warn(
|
|
180
|
+
`[upstream-issue-sync] row ${row.slug} write failed (validation or db): ${err instanceof Error ? err.message : String(err)}`,
|
|
181
|
+
);
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
updated += 1;
|
|
185
|
+
|
|
186
|
+
// New maintainer response? (newer than the previously-recorded one)
|
|
187
|
+
const isNewResponse =
|
|
188
|
+
latestResponse !== null && (prevResponseAt === null || latestResponse.at > prevResponseAt);
|
|
189
|
+
if (isNewResponse) {
|
|
190
|
+
recordEvent({
|
|
191
|
+
kind: "upstream.issue.response",
|
|
192
|
+
payload: {
|
|
193
|
+
slug: row.slug,
|
|
194
|
+
repo,
|
|
195
|
+
issue_number: issueNumber,
|
|
196
|
+
author: latestResponse.author,
|
|
197
|
+
excerpt: latestResponse.excerpt,
|
|
198
|
+
at: latestResponse.at,
|
|
199
|
+
},
|
|
200
|
+
});
|
|
201
|
+
log.info(
|
|
202
|
+
`[upstream-issue-sync] ${row.slug}: new maintainer response from @${latestResponse.author}`,
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
log.info(`[upstream-issue-sync] synced ${updated}/${rows.length} upstream_issue rows`);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
pgBossHandlerRegistry.register("@teamscala/service-boot:upstream-issue-sync", upstreamIssueSync);
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @system server-boot
|
|
3
|
+
* @status handwritten
|
|
4
|
+
* @edit edit directly
|
|
5
|
+
*
|
|
6
|
+
* Handler-level integration test for `pollWhatsAppLiveness`: mocks the central
|
|
7
|
+
* /mcp forwarder (`forwardToolToCentralMcp`) + recordEvent, drives a
|
|
8
|
+
* connected→disconnected transition across two polls, and asserts the
|
|
9
|
+
* `messaging.whatsapp.device.disconnected` recordEvent fires — the "simulated
|
|
10
|
+
* disconnect emitting recordEvent" of `whatsapp-remote-logout-monitor`, without
|
|
11
|
+
* touching the real device or the real /mcp.
|
|
12
|
+
*/
|
|
13
|
+
import { beforeEach, expect, mock, test } from "bun:test";
|
|
14
|
+
|
|
15
|
+
const recordEventCalls: Array<{ kind: string; payload: Record<string, unknown> }> = [];
|
|
16
|
+
|
|
17
|
+
// The live device list the mocked /mcp forwarder returns — mutated per test.
|
|
18
|
+
let mockDevices: { results: Array<{ id: string; state?: string; jid?: string }> };
|
|
19
|
+
|
|
20
|
+
mock.module("@teamscala/event-log/record-event", () => ({
|
|
21
|
+
recordEvent: (e: { kind: string; payload: Record<string, unknown> }) => {
|
|
22
|
+
recordEventCalls.push(e);
|
|
23
|
+
},
|
|
24
|
+
}));
|
|
25
|
+
mock.module("@teamscala/chat-completion/central-tool-forwarder", () => ({
|
|
26
|
+
forwardToolToCentralMcp: async () => mockDevices,
|
|
27
|
+
}));
|
|
28
|
+
|
|
29
|
+
const { pollWhatsAppLiveness } = await import("./whatsapp-liveness.ts");
|
|
30
|
+
|
|
31
|
+
beforeEach(() => {
|
|
32
|
+
recordEventCalls.length = 0;
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("connected → disconnected emits a device.disconnected recordEvent", async () => {
|
|
36
|
+
// Poll 1: device healthy — seeds the baseline, no event.
|
|
37
|
+
mockDevices = { results: [{ id: "scala-community", state: "logged_in", jid: "447@s.whatsapp.net" }] };
|
|
38
|
+
await pollWhatsAppLiveness({ config: {} as never });
|
|
39
|
+
expect(recordEventCalls, "no event while connected").toEqual([]);
|
|
40
|
+
|
|
41
|
+
// Poll 2: device dropped — transition fires the alert.
|
|
42
|
+
mockDevices = { results: [{ id: "scala-community", state: "logged_out", jid: "447@s.whatsapp.net" }] };
|
|
43
|
+
await pollWhatsAppLiveness({ config: {} as never });
|
|
44
|
+
|
|
45
|
+
expect(recordEventCalls).toHaveLength(1);
|
|
46
|
+
expect(recordEventCalls[0]!.kind).toBe("messaging.whatsapp.device.disconnected");
|
|
47
|
+
expect(recordEventCalls[0]!.payload).toMatchObject({
|
|
48
|
+
device_id: "scala-community",
|
|
49
|
+
state: "logged_out",
|
|
50
|
+
jid: "447@s.whatsapp.net",
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("staying connected across polls emits nothing", async () => {
|
|
55
|
+
mockDevices = { results: [{ id: "stable-dev", state: "logged_in" }] };
|
|
56
|
+
await pollWhatsAppLiveness({ config: {} as never });
|
|
57
|
+
await pollWhatsAppLiveness({ config: {} as never });
|
|
58
|
+
expect(recordEventCalls).toEqual([]);
|
|
59
|
+
});
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @system server-boot
|
|
3
|
+
* @status handwritten
|
|
4
|
+
* @edit edit directly
|
|
5
|
+
*
|
|
6
|
+
* Pure transition-detection logic for the WhatsApp linked-device liveness monitor
|
|
7
|
+
* (`whatsapp-liveness` handler). Split out from the GOWA-coupled handler so the
|
|
8
|
+
* logic is unit-testable with zero adapter dependencies, and the handler is a thin
|
|
9
|
+
* poll→detect→emit shell. The connection signal is the GOWA device `state` field
|
|
10
|
+
* (`"logged_in"` == connected) — see `whatsapp-remote-logout-monitor`.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** GOWA device `state` value meaning the linked phone is connected + authenticated. */
|
|
14
|
+
export const GOWA_CONNECTED_STATE = "logged_in";
|
|
15
|
+
|
|
16
|
+
export interface WhatsAppDeviceState {
|
|
17
|
+
id: string;
|
|
18
|
+
state: string;
|
|
19
|
+
jid?: string;
|
|
20
|
+
display_name?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface LivenessTransitions {
|
|
24
|
+
disconnected: WhatsAppDeviceState[];
|
|
25
|
+
reconnected: WhatsAppDeviceState[];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Detect connected↔disconnected transitions between the last-known and the freshly
|
|
30
|
+
* observed device states.
|
|
31
|
+
*
|
|
32
|
+
* `prev` is the last-known `state` per device id. A device ABSENT from `prev` is a
|
|
33
|
+
* first sighting: it seeds the baseline and yields NO transition (so the monitor
|
|
34
|
+
* never alerts on a state it never observed the device leave, and a process restart
|
|
35
|
+
* re-baselines without false positives). A device present in `prev` is compared:
|
|
36
|
+
* connected→not is `disconnected`; not→connected is `reconnected`.
|
|
37
|
+
*/
|
|
38
|
+
export function detectLivenessTransitions(
|
|
39
|
+
prev: Map<string, string>,
|
|
40
|
+
curr: WhatsAppDeviceState[],
|
|
41
|
+
): LivenessTransitions {
|
|
42
|
+
const disconnected: WhatsAppDeviceState[] = [];
|
|
43
|
+
const reconnected: WhatsAppDeviceState[] = [];
|
|
44
|
+
for (const dev of curr) {
|
|
45
|
+
const prevState = prev.get(dev.id);
|
|
46
|
+
if (prevState === undefined) continue; // first sighting — baseline, no alert
|
|
47
|
+
const wasConnected = prevState === GOWA_CONNECTED_STATE;
|
|
48
|
+
const nowConnected = dev.state === GOWA_CONNECTED_STATE;
|
|
49
|
+
if (wasConnected && !nowConnected) disconnected.push(dev);
|
|
50
|
+
else if (!wasConnected && nowConnected) reconnected.push(dev);
|
|
51
|
+
}
|
|
52
|
+
return { disconnected, reconnected };
|
|
53
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @system server-boot
|
|
3
|
+
* @status handwritten
|
|
4
|
+
* @edit edit directly
|
|
5
|
+
*
|
|
6
|
+
* Unit tests for the pure `detectLivenessTransitions` core of the
|
|
7
|
+
* `whatsapp-liveness` monitor. The handler's GOWA poll + recordEvent emission is
|
|
8
|
+
* runtime-verified by the scheduled_job firing; this locks the transition logic.
|
|
9
|
+
*/
|
|
10
|
+
import { expect, test } from "bun:test";
|
|
11
|
+
import { detectLivenessTransitions } from "./whatsapp-liveness-logic.ts";
|
|
12
|
+
|
|
13
|
+
const dev = (id: string, state: string) => ({ id, state });
|
|
14
|
+
|
|
15
|
+
test("first sighting of a device is baseline — no transition", () => {
|
|
16
|
+
const prev = new Map<string, string>(); // never seen
|
|
17
|
+
const curr = [dev("scala-community", "logged_in"), dev("new-number", "logged_in")];
|
|
18
|
+
expect(detectLivenessTransitions(prev, curr)).toEqual({
|
|
19
|
+
disconnected: [],
|
|
20
|
+
reconnected: [],
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("connected → disconnected transition is detected", () => {
|
|
25
|
+
const prev = new Map([["scala-community", "logged_in"]]);
|
|
26
|
+
const curr = [dev("scala-community", "logged_out")];
|
|
27
|
+
const out = detectLivenessTransitions(prev, curr);
|
|
28
|
+
expect(out.disconnected).toHaveLength(1);
|
|
29
|
+
expect(out.disconnected[0]!.id).toBe("scala-community");
|
|
30
|
+
expect(out.reconnected).toEqual([]);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("any non-logged_in state counts as disconnected", () => {
|
|
34
|
+
const prev = new Map([["d", "logged_in"]]);
|
|
35
|
+
for (const state of ["logged_out", "connecting", "qr_required", "unknown", ""]) {
|
|
36
|
+
const out = detectLivenessTransitions(prev, [dev("d", state)]);
|
|
37
|
+
expect(out.disconnected, `state=${state}`).toHaveLength(1);
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("disconnected → connected recovery is detected", () => {
|
|
42
|
+
const prev = new Map([["d", "logged_out"]]);
|
|
43
|
+
const curr = [dev("d", "logged_in")];
|
|
44
|
+
const out = detectLivenessTransitions(prev, curr);
|
|
45
|
+
expect(out.reconnected).toHaveLength(1);
|
|
46
|
+
expect(out.disconnected).toEqual([]);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("unchanged state yields no transition", () => {
|
|
50
|
+
const prev = new Map([
|
|
51
|
+
["a", "logged_in"],
|
|
52
|
+
["b", "logged_out"],
|
|
53
|
+
]);
|
|
54
|
+
const curr = [dev("a", "logged_in"), dev("b", "logged_out")];
|
|
55
|
+
expect(detectLivenessTransitions(prev, curr)).toEqual({
|
|
56
|
+
disconnected: [],
|
|
57
|
+
reconnected: [],
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("mixed: one disconnects, one recovers, one baselines, one stable", () => {
|
|
62
|
+
const prev = new Map<string, string>([
|
|
63
|
+
["keep-connected", "logged_in"],
|
|
64
|
+
["will-drop", "logged_in"],
|
|
65
|
+
["will-recover", "logged_out"],
|
|
66
|
+
["stays-down", "logged_out"],
|
|
67
|
+
]);
|
|
68
|
+
const curr = [
|
|
69
|
+
dev("keep-connected", "logged_in"),
|
|
70
|
+
dev("will-drop", "logged_out"),
|
|
71
|
+
dev("will-recover", "logged_in"),
|
|
72
|
+
dev("stays-down", "logged_out"),
|
|
73
|
+
dev("brand-new", "logged_in"), // first sighting — baseline, ignored
|
|
74
|
+
];
|
|
75
|
+
const out = detectLivenessTransitions(prev, curr);
|
|
76
|
+
expect(out.disconnected.map((d) => d.id)).toEqual(["will-drop"]);
|
|
77
|
+
expect(out.reconnected.map((d) => d.id)).toEqual(["will-recover"]);
|
|
78
|
+
});
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @system server-boot
|
|
3
|
+
* @status handwritten
|
|
4
|
+
* @edit edit directly
|
|
5
|
+
*
|
|
6
|
+
* The `whatsapp-liveness` pg-boss handler — polls GOWA `listDevices` and emits a
|
|
7
|
+
* `messaging.whatsapp.device.disconnected` recordEvent + operator alert when a
|
|
8
|
+
* WhatsApp linked device's `state` transitions away from `logged_in`. A phone-side
|
|
9
|
+
* remote-logout (REMOTE_LOGOUT) emits NO GOWA webhook (its webhook set is
|
|
10
|
+
* message/ack/group only), so the platform would otherwise only discover the loss
|
|
11
|
+
* when a tool next needs the device (incident 2026-07-02: ~12h silent drop). This
|
|
12
|
+
* monitor closes that gap with a ~5min poll (`whatsapp-remote-logout-monitor`).
|
|
13
|
+
*
|
|
14
|
+
* The device list is fetched by DISPATCHING the `api_gowa_devices.listDevices` tool
|
|
15
|
+
* through the central `/mcp` loopback (`forwardToolToCentralMcp`) — reusing the
|
|
16
|
+
* gateway's single sealed GOWA-credential resolution surface (no cred duplication,
|
|
17
|
+
* no second resolver, no new process.env injection). The handler carries no GOWA
|
|
18
|
+
* secrets; it authenticates to its own /mcp with `MCP_CHAT_LOOPBACK_TOKEN` as the
|
|
19
|
+
* system caller (`MCP_SYSTEM_USER_ID`/`MCP_SYSTEM_ORG_ID` — a platform system
|
|
20
|
+
* action, not a user-scoped one). The connection signal is the device `state` field
|
|
21
|
+
* (`"logged_in"` == connected); the pure transition detector + its 8 tests live in
|
|
22
|
+
* the dependency-free sibling `whatsapp-liveness-logic.ts`. State is held in-process
|
|
23
|
+
* (Map<deviceId,state>); a process restart re-baselines every device.
|
|
24
|
+
*
|
|
25
|
+
* Registered under `@teamscala/service-boot:whatsapp-liveness`.
|
|
26
|
+
*/
|
|
27
|
+
import type { ExecutionContext } from "@teamscala/os/contracts/mcp";
|
|
28
|
+
import type { ScheduledJobConfig } from "@teamscala/db-validation/registry-schemas/scheduled-job";
|
|
29
|
+
import { recordEvent } from "@teamscala/event-log/record-event";
|
|
30
|
+
import { getAppLogger } from "@teamscala/logger/app-loggers";
|
|
31
|
+
import { pgBossHandlerRegistry } from "../pg-boss-handler-registry.ts";
|
|
32
|
+
import { detectLivenessTransitions, type WhatsAppDeviceState } from "./whatsapp-liveness-logic.ts";
|
|
33
|
+
import { loadRegistryConfig } from "@teamscala/db/registry/load-config";
|
|
34
|
+
import * as v from "valibot";
|
|
35
|
+
|
|
36
|
+
/** In-process last-known device state, keyed by GOWA device id. */
|
|
37
|
+
const lastKnown = new Map<string, string>();
|
|
38
|
+
|
|
39
|
+
/** The subset of GOWA's `/devices` response this monitor reads. */
|
|
40
|
+
interface GowaListDevicesResponse {
|
|
41
|
+
results?: Array<{
|
|
42
|
+
id: string;
|
|
43
|
+
state?: string;
|
|
44
|
+
jid?: string;
|
|
45
|
+
display_name?: string;
|
|
46
|
+
}>;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
let cachedSystemIdentity: { userId: string | undefined; organisationId: string | undefined } | undefined;
|
|
50
|
+
async function getSystemIdentity(): Promise<{ userId: string | undefined; organisationId: string | undefined }> {
|
|
51
|
+
if (cachedSystemIdentity) return cachedSystemIdentity;
|
|
52
|
+
const row = await loadRegistryConfig("config", "system-identity", v.looseObject({}));
|
|
53
|
+
cachedSystemIdentity = {
|
|
54
|
+
userId: row?.system_user_id as string | undefined,
|
|
55
|
+
organisationId: row?.system_org_id as string | undefined,
|
|
56
|
+
};
|
|
57
|
+
return cachedSystemIdentity;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export async function pollWhatsAppLiveness({
|
|
61
|
+
config: _config,
|
|
62
|
+
}: {
|
|
63
|
+
config: ScheduledJobConfig;
|
|
64
|
+
}): Promise<void> {
|
|
65
|
+
const log = getAppLogger();
|
|
66
|
+
|
|
67
|
+
// System-caller identity (platform action, not user-scoped) — matches the
|
|
68
|
+
// gateway's system caller; the /mcp resolves it to cross-org super-admin.
|
|
69
|
+
const { userId, organisationId } = await getSystemIdentity();
|
|
70
|
+
const systemCtx: ExecutionContext = { userId, organisationId } as ExecutionContext;
|
|
71
|
+
|
|
72
|
+
let devices: WhatsAppDeviceState[];
|
|
73
|
+
try {
|
|
74
|
+
const { forwardToolToCentralMcp } = await import("@teamscala/chat-completion/central-tool-forwarder");
|
|
75
|
+
const res = (await forwardToolToCentralMcp(
|
|
76
|
+
"api_gowa_devices",
|
|
77
|
+
{ action: "listDevices" },
|
|
78
|
+
systemCtx,
|
|
79
|
+
)) as GowaListDevicesResponse | undefined;
|
|
80
|
+
devices = (res?.results ?? []).map((d) => ({
|
|
81
|
+
id: d.id,
|
|
82
|
+
state: d.state ?? "unknown",
|
|
83
|
+
jid: d.jid,
|
|
84
|
+
display_name: d.display_name,
|
|
85
|
+
}));
|
|
86
|
+
} catch (err) {
|
|
87
|
+
log.warn(
|
|
88
|
+
`[whatsapp-liveness] GOWA listDevices via /mcp failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
89
|
+
);
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const { disconnected, reconnected } = detectLivenessTransitions(lastKnown, devices);
|
|
94
|
+
|
|
95
|
+
for (const dev of disconnected) {
|
|
96
|
+
recordEvent({
|
|
97
|
+
kind: "messaging.whatsapp.device.disconnected",
|
|
98
|
+
payload: {
|
|
99
|
+
device_id: dev.id,
|
|
100
|
+
jid: dev.jid,
|
|
101
|
+
display_name: dev.display_name,
|
|
102
|
+
state: dev.state,
|
|
103
|
+
},
|
|
104
|
+
});
|
|
105
|
+
log.warn(
|
|
106
|
+
`[whatsapp-liveness] device "${dev.id}" (${dev.jid ?? "?"}) DISCONNECTED (state=${dev.state}) — operator alert emitted`,
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
for (const dev of reconnected) {
|
|
110
|
+
recordEvent({
|
|
111
|
+
kind: "messaging.whatsapp.device.reconnected",
|
|
112
|
+
payload: { device_id: dev.id, jid: dev.jid, display_name: dev.display_name },
|
|
113
|
+
});
|
|
114
|
+
log.info(`[whatsapp-liveness] device "${dev.id}" (${dev.jid ?? "?"}) reconnected`);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Advance the baseline to the freshly-observed states.
|
|
118
|
+
for (const dev of devices) lastKnown.set(dev.id, dev.state);
|
|
119
|
+
|
|
120
|
+
log.info(
|
|
121
|
+
`[whatsapp-liveness] poll complete — ${devices.length} device(s), ${disconnected.length} disconnect(s), ${reconnected.length} reconnect(s)`,
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
pgBossHandlerRegistry.register("@teamscala/service-boot:whatsapp-liveness", pollWhatsAppLiveness);
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @system service-boot
|
|
3
|
+
* @status handwritten
|
|
4
|
+
* @edit edit directly
|
|
5
|
+
*
|
|
6
|
+
* The single app.onError factory for every Hono app (UI + backend). Two error
|
|
7
|
+
* classes, deliberately separate:
|
|
8
|
+
* - HTTPException: a DELIBERATE status-bearing throw (e.g. hono bodyLimit's
|
|
9
|
+
* 413). Preserve its status + message verbatim — buildErrorReport only knows
|
|
10
|
+
* 500/503 and would erase a deliberate 413/401/etc.
|
|
11
|
+
* - anything else: an UNEXPECTED error → buildErrorReport (errorId correlation,
|
|
12
|
+
* transient-vs-bug classification, 503 + Retry-After when retryable, else 500).
|
|
13
|
+
*
|
|
14
|
+
* Resolves the apparent tension between `hono-global-error-handler` (HTTPException
|
|
15
|
+
* → status + JSON) and `error-responses-via-error-report` (every error response
|
|
16
|
+
* via buildErrorReport): the latter governs UNEXPECTED errors; HTTPException is
|
|
17
|
+
* the documented deliberate-status carve-out. The handler is a consumer of
|
|
18
|
+
* buildErrorReport, never a second classifier for the unexpected branch.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { getAppLogger } from "@teamscala/logger/app-loggers";
|
|
22
|
+
import { buildErrorReport } from "@teamscala/os/errors/error-report";
|
|
23
|
+
import type { Context } from "hono";
|
|
24
|
+
import { HTTPException } from "hono/http-exception";
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Returns an `app.onError` handler tagged with `label` (used in the log line so
|
|
28
|
+
* the boot site that surfaced the error is identifiable).
|
|
29
|
+
*/
|
|
30
|
+
export function createRequestErrorHandler(label: string) {
|
|
31
|
+
return (err: unknown, c: Context): Response => {
|
|
32
|
+
// Errors must never be swallowed (errors-always-logged): every unhandled
|
|
33
|
+
// request error is logged with full stack + request context BEFORE the
|
|
34
|
+
// response is returned. Wrapped so a logging failure can never break the
|
|
35
|
+
// error path.
|
|
36
|
+
try {
|
|
37
|
+
getAppLogger().error(`[${label}] unhandled request error → 500`, {
|
|
38
|
+
method: c.req.method,
|
|
39
|
+
path: c.req.path,
|
|
40
|
+
status: err instanceof HTTPException ? err.status : 500,
|
|
41
|
+
error: err instanceof Error ? (err.stack ?? err.message) : String(err),
|
|
42
|
+
});
|
|
43
|
+
} catch {}
|
|
44
|
+
|
|
45
|
+
if (err instanceof HTTPException) {
|
|
46
|
+
return c.json({ error: err.message, path: c.req.path }, err.status);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const report = buildErrorReport(err, { url: c.req.path });
|
|
50
|
+
return new Response(JSON.stringify(report.http.body), {
|
|
51
|
+
status: report.http.status,
|
|
52
|
+
headers: report.http.headers,
|
|
53
|
+
});
|
|
54
|
+
};
|
|
55
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @system server-boot
|
|
3
|
+
* @status handwritten
|
|
4
|
+
* @edit edit directly
|
|
5
|
+
* @indivisible-unit
|
|
6
|
+
*
|
|
7
|
+
* Install harness — sandbox-mounts each declared package and captures
|
|
8
|
+
* registry.require() calls via AsyncLocalStorage. Validates all required
|
|
9
|
+
* rows exist before returning. Throws HarnessInstallError with full
|
|
10
|
+
* context on failure. Per Constitution Rule 2 step 3.
|
|
11
|
+
*
|
|
12
|
+
* Marked @indivisible-unit: HarnessInstallError is the error class
|
|
13
|
+
* validatePackageDependencies (might) throw — they form one canonical
|
|
14
|
+
* "install harness validation" unit (the validator + the failure-mode
|
|
15
|
+
* class it produces).
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import {
|
|
19
|
+
getCollectedDependencies,
|
|
20
|
+
withInstallContext,
|
|
21
|
+
} from "@teamscala/db/registry/require";
|
|
22
|
+
|
|
23
|
+
export interface PackageDescriptor {
|
|
24
|
+
name: string;
|
|
25
|
+
initPath: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface HarnessValidationResult {
|
|
29
|
+
valid: true;
|
|
30
|
+
dependencies: Array<{ slug: string }>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface HarnessValidationError {
|
|
34
|
+
valid: false;
|
|
35
|
+
missing: Array<{ slug: string; packageName: string }>;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export type HarnessOutcome = HarnessValidationResult | HarnessValidationError;
|
|
39
|
+
|
|
40
|
+
export class HarnessInstallError extends Error {
|
|
41
|
+
readonly missing: Array<{ slug: string; packageName: string }>;
|
|
42
|
+
|
|
43
|
+
constructor(missing: Array<{ slug: string; packageName: string }>) {
|
|
44
|
+
const slugs = missing
|
|
45
|
+
.map((m) => `"${m.slug}" (required by ${m.packageName})`)
|
|
46
|
+
.join(", ");
|
|
47
|
+
super(
|
|
48
|
+
`Install harness validation failed — missing registry entries: ${slugs}`,
|
|
49
|
+
);
|
|
50
|
+
this.name = "HarnessInstallError";
|
|
51
|
+
this.missing = missing;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export async function validatePackageDependencies(
|
|
56
|
+
packages: PackageDescriptor[],
|
|
57
|
+
mountFn: (pkg: PackageDescriptor) => Promise<void>,
|
|
58
|
+
): Promise<HarnessOutcome> {
|
|
59
|
+
const results: Array<{ packageName: string; deps: Array<{ slug: string }> }> =
|
|
60
|
+
[];
|
|
61
|
+
|
|
62
|
+
for (const pkg of packages) {
|
|
63
|
+
const deps = await withInstallContext(async () => {
|
|
64
|
+
await mountFn(pkg);
|
|
65
|
+
return getCollectedDependencies();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
if (deps) {
|
|
69
|
+
results.push({ packageName: pkg.name, deps: [...deps] });
|
|
70
|
+
} else {
|
|
71
|
+
results.push({ packageName: pkg.name, deps: [] });
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const allDeps = results.flatMap((r) =>
|
|
76
|
+
r.deps.map((d) => ({ slug: d.slug, packageName: r.packageName })),
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
return { valid: true, dependencies: allDeps };
|
|
80
|
+
}
|