@agent-native/core 0.176.4-nightly-20260902024948 → 0.176.4-nightly-20260902074703

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.
@@ -18,6 +18,12 @@ export type ChatHealthAlertOutcome = {
18
18
  turns: number;
19
19
  badRate: number;
20
20
  recipients: number;
21
+ } | {
22
+ status: "delivery-failed";
23
+ reason: string;
24
+ } | {
25
+ status: "persistence-failed";
26
+ reason: string;
21
27
  } | {
22
28
  status: "check-failed";
23
29
  reason: string;
@@ -1,8 +1,10 @@
1
+ import { randomUUID } from "node:crypto";
1
2
  import { getDbExec } from "../db/client.js";
2
- import { notify } from "../notifications/registry.js";
3
- import { getSetting, putSetting } from "../settings/store.js";
3
+ import { notifyWithDelivery } from "../notifications/registry.js";
4
+ import { runWithRequestContext } from "../server/request-context.js";
5
+ import { deleteSettingIfValue, mutateSetting } from "../settings/store.js";
4
6
  /**
5
- * Pages the people who can act when an app's chat stops answering.
7
+ * Sends one Slack alert when an app's chat stops answering.
6
8
  *
7
9
  * The detector for this already existed as `scripts/chat-health.mjs --strict`,
8
10
  * correctly calibrated and exiting 1 on a partial outage — but nothing ever ran
@@ -27,7 +29,9 @@ const MIN_TURNS = 5;
27
29
  const BAD_RATE_THRESHOLD = 0.5;
28
30
  /** One page per outage, not one per sweep. */
29
31
  const COOLDOWN_MS = 60 * 60_000;
30
- const LAST_ALERT_SETTING_KEY = "chat-health-alert:last-paged-at";
32
+ /** Slack sends time out well inside this lease; failed sends release it early. */
33
+ const CLAIM_LEASE_MS = 5 * 60_000;
34
+ const LAST_ALERT_SETTING_KEY = "chat-health-alert:last-slack-alert-at";
31
35
  /**
32
36
  * Scores the LAST run of each turn in the window, matching how
33
37
  * `scripts/chat-health.mjs` reports so a page and the CLI never disagree.
@@ -57,16 +61,38 @@ async function countRecentTurns(since) {
57
61
  bad: Number(row?.bad ?? 0),
58
62
  };
59
63
  }
60
- /** Owners and admins — the people who can actually change a model or a key. */
61
- async function alertRecipients() {
64
+ /** Use one owner/admin only when the app has an unambiguous org scope. */
65
+ async function alertOwner() {
62
66
  const { rows } = await getDbExec().execute({
63
- sql: `SELECT DISTINCT email FROM org_members
64
- WHERE role IN ('owner', 'admin')`,
67
+ sql: `SELECT org_id, email, role FROM org_members
68
+ WHERE role IN ('owner', 'admin')
69
+ ORDER BY CASE role WHEN 'owner' THEN 0 WHEN 'admin' THEN 1 ELSE 2 END, email`,
65
70
  args: [],
66
71
  });
67
- return rows
68
- .map((r) => String(r.email ?? ""))
69
- .filter(Boolean);
72
+ const orgIds = new Set(rows.map((row) => String(row.org_id ?? "")));
73
+ if (orgIds.size !== 1 || orgIds.has(""))
74
+ return null;
75
+ const recipient = rows.find((row) => {
76
+ const role = row.role;
77
+ return role === "owner" || role === "admin";
78
+ });
79
+ const email = String(recipient?.email ?? "");
80
+ const [orgId] = [...orgIds];
81
+ return email && orgId ? { owner: email, orgId } : null;
82
+ }
83
+ async function releaseAlertClaim(claimId, claimExpiresAt) {
84
+ try {
85
+ await deleteSettingIfValue(LAST_ALERT_SETTING_KEY, {
86
+ claimId,
87
+ claimExpiresAt,
88
+ });
89
+ return null;
90
+ }
91
+ catch (error) {
92
+ const reason = "The Slack alert claim could not be released.";
93
+ console.error(`[chat-health-alert] ${reason}`, error);
94
+ return reason;
95
+ }
70
96
  }
71
97
  export async function checkChatHealthAndAlert(now = Date.now()) {
72
98
  let counts;
@@ -84,58 +110,116 @@ export async function checkChatHealthAndAlert(now = Date.now()) {
84
110
  if (badRate < BAD_RATE_THRESHOLD) {
85
111
  return { status: "healthy", turns: counts.turns, badRate };
86
112
  }
87
- // A cooldown stamp we could not READ is not a cooldown that is absent:
88
- // treating it as absent pages on every sweep for as long as settings stay
89
- // unreadable, which is exactly the spam the cooldown exists to prevent.
90
- let stored;
113
+ // Claim the page before awaiting the external send. The short lease keeps
114
+ // overlapping sweeps from both sending; failed sends release it below,
115
+ // while a crashed send becomes retryable after the lease.
116
+ const claimId = randomUUID();
117
+ const claimExpiresAt = now + CLAIM_LEASE_MS;
118
+ let claim;
91
119
  try {
92
- stored = (await getSetting(LAST_ALERT_SETTING_KEY));
120
+ claim = await mutateSetting(LAST_ALERT_SETTING_KEY, (current) => {
121
+ const lastPagedAt = Number(current?.at ?? 0);
122
+ const existingClaimExpiresAt = Number(current?.claimExpiresAt ?? 0);
123
+ if ((Number.isFinite(lastPagedAt) && now - lastPagedAt < COOLDOWN_MS) ||
124
+ (Number.isFinite(existingClaimExpiresAt) &&
125
+ existingClaimExpiresAt > now)) {
126
+ return current ?? {};
127
+ }
128
+ return { claimId, claimExpiresAt };
129
+ });
93
130
  }
94
131
  catch (error) {
95
132
  return { status: "check-failed", reason: String(error) };
96
133
  }
97
- const lastPagedAt = Number(stored?.at ?? 0);
98
- if (Number.isFinite(lastPagedAt) && now - lastPagedAt < COOLDOWN_MS) {
134
+ if (String(claim.claimId ?? "") !== claimId) {
135
+ const lastPagedAt = Number(claim.at ?? 0);
136
+ const retryAfterMs = Number.isFinite(lastPagedAt) && now - lastPagedAt < COOLDOWN_MS
137
+ ? COOLDOWN_MS - (now - lastPagedAt)
138
+ : Math.max(0, Number(claim.claimExpiresAt ?? 0) - now);
99
139
  return {
100
140
  status: "cooldown",
101
- retryAfterMs: COOLDOWN_MS - (now - lastPagedAt),
141
+ retryAfterMs,
102
142
  };
103
143
  }
104
- let recipients;
144
+ let recipient;
105
145
  try {
106
- recipients = await alertRecipients();
146
+ recipient = await alertOwner();
107
147
  }
108
148
  catch (error) {
109
- return { status: "check-failed", reason: String(error) };
149
+ const releaseReason = await releaseAlertClaim(claimId, claimExpiresAt);
150
+ return {
151
+ status: "check-failed",
152
+ reason: releaseReason
153
+ ? `${String(error)} ${releaseReason}`
154
+ : String(error),
155
+ };
156
+ }
157
+ if (!recipient) {
158
+ const reason = "No single owner/admin organization scope is available for Slack health alerts.";
159
+ const releaseReason = await releaseAlertClaim(claimId, claimExpiresAt);
160
+ return {
161
+ status: "delivery-failed",
162
+ reason: releaseReason ? `${reason} ${releaseReason}` : reason,
163
+ };
110
164
  }
111
165
  const pct = Math.round(badRate * 100);
112
- for (const owner of recipients) {
113
- await notify({
166
+ let delivery;
167
+ try {
168
+ delivery = await runWithRequestContext({ userEmail: recipient.owner, orgId: recipient.orgId }, () => notifyWithDelivery({
114
169
  severity: "critical",
115
170
  title: `Chat is failing: ${pct}% of turns ended without an answer`,
116
171
  body: `${counts.bad} of ${counts.turns} turns in the last hour ended without ` +
117
172
  `an answer. Run \`node scripts/chat-health.mjs --hours 1\` for the ` +
118
173
  `per-reason breakdown.`,
174
+ channels: ["slack"],
119
175
  metadata: {
120
176
  turns: counts.turns,
121
177
  bad: counts.bad,
122
178
  badRate,
123
179
  windowMs: WINDOW_MS,
124
180
  },
125
- }, { owner }).catch((error) => {
126
- // One undeliverable recipient must not suppress the rest.
127
- console.error("[chat-health-alert] notify failed for a recipient:", error);
128
- });
181
+ }, { owner: recipient.owner }));
182
+ }
183
+ catch (error) {
184
+ console.error("[chat-health-alert] Slack delivery failed:", error);
185
+ const releaseReason = await releaseAlertClaim(claimId, claimExpiresAt);
186
+ return {
187
+ status: "delivery-failed",
188
+ reason: releaseReason
189
+ ? `${String(error)} ${releaseReason}`
190
+ : String(error),
191
+ };
192
+ }
193
+ if (!delivery.deliveredChannels.includes("slack")) {
194
+ const reason = "Slack health alert was not delivered.";
195
+ console.error(`[chat-health-alert] ${reason}`);
196
+ const releaseReason = await releaseAlertClaim(claimId, claimExpiresAt);
197
+ return {
198
+ status: "delivery-failed",
199
+ reason: releaseReason ? `${reason} ${releaseReason}` : reason,
200
+ };
201
+ }
202
+ // Finalize only after Slack confirms delivery. The claim id keeps a slow or
203
+ // expired sender from overwriting a newer claim's cooldown.
204
+ try {
205
+ const finalized = await mutateSetting(LAST_ALERT_SETTING_KEY, (current) => String(current?.claimId ?? "") === claimId
206
+ ? { at: now, claimId }
207
+ : (current ?? {}));
208
+ if (String(finalized.claimId ?? "") !== claimId) {
209
+ const reason = "Slack delivered, but its alert cooldown claim was lost before persistence.";
210
+ console.error(`[chat-health-alert] ${reason}`);
211
+ return { status: "persistence-failed", reason };
212
+ }
129
213
  }
130
- // Stamped only after an attempt was actually made, so a failed page retries
131
- // on the next sweep instead of being silenced by its own cooldown.
132
- await putSetting(LAST_ALERT_SETTING_KEY, { at: now }).catch((error) => {
214
+ catch (error) {
215
+ const reason = "Slack delivered, but the alert cooldown could not be persisted.";
133
216
  console.error("[chat-health-alert] could not stamp cooldown:", error);
134
- });
217
+ return { status: "persistence-failed", reason };
218
+ }
135
219
  return {
136
220
  status: "alerted",
137
221
  turns: counts.turns,
138
222
  badRate,
139
- recipients: recipients.length,
223
+ recipients: 1,
140
224
  };
141
225
  }
@@ -62,11 +62,11 @@ export declare const postAwareness: import("h3").EventHandlerWithFetch<import("h
62
62
  error: string;
63
63
  states?: undefined;
64
64
  } | {
65
- error?: undefined;
66
65
  states: {
67
66
  clientId: number;
68
67
  state: string;
69
68
  }[];
69
+ error?: undefined;
70
70
  }>>;
71
71
  /**
72
72
  * GET /_agent-native/collab/:docId/users
@@ -77,9 +77,9 @@ export declare const getActiveUsers: import("h3").EventHandlerWithFetch<import("
77
77
  error: string;
78
78
  users?: undefined;
79
79
  } | {
80
- error?: undefined;
81
80
  users: {
82
81
  clientId: number;
83
82
  lastSeen: number;
84
83
  }[];
84
+ error?: undefined;
85
85
  }>>;
@@ -26,8 +26,8 @@ export declare const getCollabState: import("h3").EventHandlerWithFetch<import("
26
26
  * Body: { update: string (base64), requestSource?: string }
27
27
  */
28
28
  export declare const postCollabUpdate: import("h3").EventHandlerWithFetch<import("h3").EventHandlerRequest, Promise<{
29
- error: string;
30
29
  ok?: undefined;
30
+ error: string;
31
31
  } | {
32
32
  error?: undefined;
33
33
  ok: boolean;
@@ -13,8 +13,8 @@
13
13
  * Body: { json: any, fieldName?: string, type?: "map"|"array", requestSource?: string }
14
14
  */
15
15
  export declare const postCollabJson: import("h3").EventHandlerWithFetch<import("h3").EventHandlerRequest, Promise<{
16
- ok?: undefined;
17
16
  error: string;
17
+ ok?: undefined;
18
18
  } | {
19
19
  error?: undefined;
20
20
  ok: boolean;
@@ -3,18 +3,18 @@ declare const _default: import("../../action.js").ActionDefinition<{
3
3
  url?: string;
4
4
  filename?: string;
5
5
  }, {
6
- id?: undefined;
7
6
  error: string;
8
7
  configured?: undefined;
9
8
  connectPath?: undefined;
10
9
  url?: undefined;
10
+ id?: undefined;
11
11
  provider?: undefined;
12
12
  } | {
13
- id?: undefined;
14
13
  error: string;
15
14
  configured: boolean;
16
15
  connectPath: string;
17
16
  url?: undefined;
17
+ id?: undefined;
18
18
  provider?: undefined;
19
19
  } | {
20
20
  error?: undefined;
@@ -32,6 +32,7 @@ import { getKeyAllowlist, getResolvedKeyAllowlist, resolveKeyReferencesWithReque
32
32
  import { sendEmail } from "../server/email.js";
33
33
  import { registerNotificationChannel } from "./registry.js";
34
34
  let _registered = false;
35
+ const SLACK_DELIVERY_TIMEOUT_MS = 10_000;
35
36
  export function registerBuiltinNotificationChannels() {
36
37
  if (_registered)
37
38
  return;
@@ -89,6 +90,7 @@ function createSlackWebhookChannel(envUrlTemplate) {
89
90
  const res = await ssrfSafeFetch(url, {
90
91
  method: "POST",
91
92
  headers,
93
+ signal: AbortSignal.timeout(SLACK_DELIVERY_TIMEOUT_MS),
92
94
  body: JSON.stringify({
93
95
  text: slackText(input.severity, input.title, input.body),
94
96
  blocks: [
@@ -11,14 +11,14 @@
11
11
  * DELETE /_agent-native/notifications/:id — delete
12
12
  */
13
13
  export declare function createNotificationsHandler(): import("h3").EventHandlerWithFetch<import("h3").EventHandlerRequest, Promise<"" | import("./types.js").Notification[] | {
14
- error?: undefined;
15
14
  count: number;
16
15
  updated?: undefined;
16
+ error?: undefined;
17
17
  ok?: undefined;
18
18
  } | {
19
- error?: undefined;
20
19
  count?: undefined;
21
20
  updated: number;
21
+ error?: undefined;
22
22
  ok?: undefined;
23
23
  } | {
24
24
  count?: undefined;
@@ -26,8 +26,8 @@ export declare function createNotificationsHandler(): import("h3").EventHandlerW
26
26
  error: string;
27
27
  ok?: undefined;
28
28
  } | {
29
- error?: undefined;
30
29
  count?: undefined;
31
30
  updated?: undefined;
31
+ error?: undefined;
32
32
  ok: boolean;
33
33
  }>>;
@@ -43,8 +43,8 @@ export declare function createObservabilityHandler(): import("h3").EventHandlerW
43
43
  } | {
44
44
  summary: import("./types.js").TraceSummary;
45
45
  spans: import("./types.js").TraceSpan[];
46
- id?: undefined;
47
46
  error?: undefined;
47
+ id?: undefined;
48
48
  ok?: undefined;
49
49
  } | {
50
50
  summary?: undefined;
@@ -55,13 +55,13 @@ export declare function createObservabilityHandler(): import("h3").EventHandlerW
55
55
  } | {
56
56
  summary?: undefined;
57
57
  spans?: undefined;
58
- id?: undefined;
59
58
  error: any;
59
+ id?: undefined;
60
60
  ok?: undefined;
61
61
  } | {
62
62
  summary?: undefined;
63
63
  spans?: undefined;
64
- id?: undefined;
65
- error?: undefined;
66
64
  ok: boolean;
65
+ error?: undefined;
66
+ id?: undefined;
67
67
  }>>;
@@ -48,8 +48,8 @@ export declare function handleUpdateResource(event: any): Promise<import("./stor
48
48
  }>;
49
49
  /** DELETE /_agent-native/resources/:id — delete a resource */
50
50
  export declare function handleDeleteResource(event: any): Promise<{
51
- error: string;
52
51
  ok?: undefined;
52
+ error: string;
53
53
  } | {
54
54
  error?: undefined;
55
55
  ok: boolean;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agent-native/core",
3
- "version": "0.176.4-nightly-20260902024948",
3
+ "version": "0.176.4-nightly-20260902074703",
4
4
  "description": "Framework for agent-native application development — where AI agents and UI share SQL state, actions, and context",
5
5
  "homepage": "https://github.com/BuilderIO/agent-native#readme",
6
6
  "bugs": {