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

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/corpus/README.md CHANGED
@@ -31,4 +31,4 @@ rg -n "defineAction|useActionQuery" node_modules/@agent-native/core/corpus
31
31
 
32
32
  ## Generated Counts
33
33
 
34
- - template files: 8839
34
+ - template files: 8840
@@ -22,8 +22,9 @@ delegation, but A2A replies cannot render MCP App pickers.
22
22
  `callerAppId` with the calling app id (`slides`, `design`, `content`,
23
23
  `mail`) so the Assets audit log can group cross-agent generations.
24
24
  3. Treat image batches as complete when the action returns. Use returned
25
- successful `images` entries directly; only regenerate slots that returned
26
- `ok: false`.
25
+ successful compact `images` entries directly; only regenerate slots that
26
+ returned `ok: false`. Use `get-asset` for full asset details and
27
+ `get-audit-run` / `list-audit-runs` for prompts, references, and settings.
27
28
  A result carrying `draftPendingApproval: true` means the caller can draft in
28
29
  that kit but not save into it. The image is real and usable — say it is
29
30
  waiting on a kit editor instead of reporting it as saved, and do not retry.
@@ -33,12 +34,13 @@ delegation, but A2A replies cannot render MCP App pickers.
33
34
  5. When a human designer needs to continue the work, create or update a
34
35
  generation session and preserve the returned `sessionId`.
35
36
  6. For video, call `generate-video` and then `refresh-generation-run` until the run completes.
36
- 7. Preserve returned `assetId`, `runId`, `previewUrl`, `downloadUrl`, and
37
- `embedPath` exactly.
37
+ 7. Preserve returned `id`, `runId`, `previewUrl`, `downloadUrl`, and `embedUrl`
38
+ exactly.
38
39
  8. Insert chosen/exported URLs into the caller's artifact. Design callers should
39
40
  call `insert-asset` after the picker returns a selected asset.
40
- 9. On feedback, call `refine-image` with the prior `assetId`, `source: "a2a"`,
41
- and the same `callerAppId`, then replace only the affected destination.
41
+ 9. On feedback, call `refine-image` with `assetId` set to the prior result's
42
+ `id`, `source: "a2a"`, and the same `callerAppId`, then replace only the
43
+ affected destination.
42
44
 
43
45
  ## Audit Trail
44
46
 
@@ -89,9 +89,10 @@ claiming a brand match.
89
89
  3. For one asset, call `generate-image`; for multiple independent slots, call
90
90
  `generate-image-batch` with stable `slotId` values.
91
91
  4. Image generation actions are synchronous. After `generate-image` or
92
- `generate-image-batch` returns, use its returned `images` / asset fields
92
+ `generate-image-batch` returns, use its compact `images` / asset summaries
93
93
  directly; do not call `get-generation-run`, `refresh-generation-run`, or
94
- regenerate just to verify image runs.
94
+ regenerate just to verify image runs. Use `get-asset` for full asset details
95
+ and the audit-run actions for prompts, references, and settings.
95
96
  A result with `draftPendingApproval: true` came from a kit the user can draft
96
97
  in but not save into. Offer the candidate and say it needs a kit editor to be
97
98
  saved; `save-generated-image` will refuse, so do not call it or retry. Video
@@ -112,9 +113,10 @@ claiming a brand match.
112
113
  another ratio (16:9, 9:16, 4:5, 21:9, …), pick a Gemini model rather than
113
114
  `gpt-image-2` — an unsupported pairing is rejected upstream. Source of truth
114
115
  is `supportedAspectRatiosForModel` / `MODEL_ASPECT_RATIOS` in `shared/api.ts`.
115
- 8. Preserve returned `assetId`, `runId`, `previewUrl`, and `downloadUrl`.
116
- Preserve the immutable `contextPackId` and reuse labels on both generation
117
- run and output-asset metadata; rendered pixels are not provenance.
116
+ 8. Direct generation returns `id`; picker selections return `assetId`. Preserve
117
+ that asset identifier with `runId`, `previewUrl`, `downloadUrl`, and
118
+ `embedUrl`. Preserve the immutable `contextPackId` and reuse labels on both
119
+ generation run and output-asset metadata; rendered pixels are not provenance.
118
120
  9. Use `refine-image` for feedback on an existing asset, `edit-image` for
119
121
  targeted changes, and `restyle-image` with `subjectAssetId` and
120
122
  `styleStrength` for subject-preserving brand restyles.
@@ -54,10 +54,13 @@ evidence, screenshots, or exact logos.
54
54
  Use `generate-image-batch` with stable `slotId`s so the shared generation tray
55
55
  can show live slots.
56
56
  - `generate-image` and `generate-image-batch` are synchronous for images. One
57
- batch call should produce the requested candidates and return their asset
58
- IDs/URLs; do not follow it with `get-generation-run`,
57
+ batch call should produce the requested candidates and return compact asset
58
+ summaries with IDs and URLs; do not follow it with `get-generation-run`,
59
59
  `refresh-generation-run`, or more generation unless the user asks for another
60
60
  direction or the returned slot has `ok: false`.
61
+ - Use `get-asset` when full asset details are needed. Use `get-audit-run` or
62
+ `list-audit-runs` for the prompt, compiled prompt, references, and generation
63
+ settings.
61
64
  - For repeatable deliverables, honor a `template` @mention as `templateId` or
62
65
  call `list-templates` when choosing one. Pass the template through
63
66
  `generate-image`, `generate-image-batch`, `refine-image`, or
@@ -438,6 +438,112 @@ export function serializeAsset(
438
438
  };
439
439
  }
440
440
 
441
+ /**
442
+ * Caller-facing generation result. Generation internals are replayed into the
443
+ * model context on later turns and can be truncated by delegated/ledger caps;
444
+ * full details remain available through get-asset and the audit-run actions.
445
+ */
446
+ export function serializeAssetSummary(row: {
447
+ id: string;
448
+ generationRunId?: string | null;
449
+ title?: string | null;
450
+ libraryId: string;
451
+ collectionId?: string | null;
452
+ status: string;
453
+ mediaType?: string | null;
454
+ aspectRatio?: string | null;
455
+ width?: number | null;
456
+ height?: number | null;
457
+ mimeType: string;
458
+ objectKey: string;
459
+ thumbnailObjectKey?: string | null;
460
+ }) {
461
+ const urls = assetUrls(row);
462
+ return {
463
+ id: row.id,
464
+ runId: row.generationRunId ?? null,
465
+ artifactType: "image" as const,
466
+ title: row.title ?? null,
467
+ libraryId: row.libraryId,
468
+ collectionId: row.collectionId ?? null,
469
+ status: row.status,
470
+ mediaType:
471
+ row.mediaType ?? (row.mimeType.startsWith("video/") ? "video" : "image"),
472
+ aspectRatio: row.aspectRatio ?? null,
473
+ width: row.width ?? null,
474
+ height: row.height ?? null,
475
+ mimeType: row.mimeType,
476
+ url: urls.url,
477
+ previewUrl: urls.previewUrl,
478
+ downloadUrl: urls.downloadUrl,
479
+ embedUrl: urls.embedUrl,
480
+ };
481
+ }
482
+
483
+ export function serializeAssetListItem(
484
+ row: {
485
+ id: string;
486
+ libraryId: string;
487
+ collectionId?: string | null;
488
+ folderId?: string | null;
489
+ mediaType?: string | null;
490
+ role: string;
491
+ status: string;
492
+ title?: string | null;
493
+ description?: string | null;
494
+ altText?: string | null;
495
+ prompt?: string | null;
496
+ model?: string | null;
497
+ aspectRatio?: string | null;
498
+ mimeType: string;
499
+ width?: number | null;
500
+ height?: number | null;
501
+ durationSeconds?: number | null;
502
+ objectKey: string;
503
+ thumbnailObjectKey?: string | null;
504
+ metadata?: string | null;
505
+ },
506
+ lineage: AssetLineageSummary | null = null,
507
+ ) {
508
+ const metadata = parseJson<Record<string, unknown>>(row.metadata, {});
509
+ const urls = assetUrls(row);
510
+ return {
511
+ id: row.id,
512
+ libraryId: row.libraryId,
513
+ collectionId: row.collectionId ?? null,
514
+ folderId: row.folderId ?? null,
515
+ mediaType:
516
+ row.mediaType ?? (row.mimeType.startsWith("video/") ? "video" : "image"),
517
+ role: row.role,
518
+ status: row.status,
519
+ category: metadata.category ?? null,
520
+ title: row.title ?? null,
521
+ description: row.description ?? metadata.description ?? null,
522
+ altText: row.altText ?? null,
523
+ prompt: row.prompt ?? null,
524
+ model: row.model ?? null,
525
+ aspectRatio: row.aspectRatio ?? null,
526
+ mimeType: row.mimeType,
527
+ width: row.width ?? null,
528
+ height: row.height ?? null,
529
+ durationSeconds: row.durationSeconds ?? null,
530
+ metadata: {
531
+ category: metadata.category ?? null,
532
+ intent: metadata.intent ?? null,
533
+ description: metadata.description ?? null,
534
+ originalName: metadata.originalName ?? null,
535
+ provider: metadata.provider ?? null,
536
+ },
537
+ lineage,
538
+ url: urls.url,
539
+ previewUrl: urls.previewUrl,
540
+ thumbnailUrl: urls.thumbnailUrl,
541
+ downloadUrl: urls.downloadUrl,
542
+ embedPath: urls.embedPath,
543
+ embedUrl: urls.embedUrl,
544
+ };
545
+ }
546
+
441
547
  export async function getAssetOrThrow(id: string) {
442
548
  const db = getDb();
443
549
  const [asset] = await db
@@ -70,7 +70,7 @@ const imageBatchAgentInputSchema = z.object({
70
70
 
71
71
  export default defineAction({
72
72
  description:
73
- "Generate several brand-consistent images in parallel from one brand kit/library. Use @brand-kit mentions as libraryId and @preset mentions as presetId when present. If no preset is tagged, call list-generation-presets first and use a matching preset's presetId; the user may not know presets exist. Generate presetless only when no preset matches the request. This is synchronous for images: one call waits for every slot and returns final image artifacts. Use this for slide decks, landing pages, and multi-slot design work. Do not call get-generation-run or refresh-generation-run after a normal image batch result.",
73
+ "Generate several brand-consistent images in parallel from one brand kit/library. Use @brand-kit mentions as libraryId and @preset mentions as presetId when present. If no preset is tagged, call list-generation-presets first and use a matching preset's presetId; the user may not know presets exist. Generate presetless only when no preset matches the request. This is synchronous for images: one call waits for every slot and returns compact image summaries; use get-asset for full asset details and get-audit-run for prompt, references, and settings. Use this for slide decks, landing pages, and multi-slot design work. Do not call get-generation-run or refresh-generation-run after a normal image batch result.",
74
74
  schema: z.object({
75
75
  libraryId: z
76
76
  .string()
@@ -296,15 +296,45 @@ export default defineAction({
296
296
  .where(eq(schema.assetGenerationSessions.id, base.sessionId));
297
297
  }
298
298
  }
299
+ const creativeContextProvenance = firstCreativeContextProvenance(results);
299
300
  return {
300
301
  count: results.length,
301
302
  images: results.map((result, index) =>
302
303
  serializeBatchResult(slots[index].slotId, result),
303
304
  ),
305
+ ...creativeContextProvenance,
304
306
  };
305
307
  },
306
308
  });
307
309
 
310
+ function firstCreativeContextProvenance(
311
+ results: PromiseSettledResult<Record<string, unknown>>[],
312
+ ) {
313
+ for (const result of results) {
314
+ if (result.status !== "fulfilled") continue;
315
+ const contextMode = result.value.contextMode;
316
+ if (
317
+ contextMode !== "off" &&
318
+ contextMode !== "auto" &&
319
+ contextMode !== "pinned"
320
+ ) {
321
+ continue;
322
+ }
323
+ const contextPackId = result.value.contextPackId;
324
+ return {
325
+ contextMode,
326
+ contextPackId:
327
+ typeof contextPackId === "string" || contextPackId === null
328
+ ? contextPackId
329
+ : null,
330
+ reuseLabels: Array.isArray(result.value.reuseLabels)
331
+ ? result.value.reuseLabels
332
+ : [],
333
+ };
334
+ }
335
+ return {};
336
+ }
337
+
308
338
  function firstSuccessfulAssetId(
309
339
  results: PromiseSettledResult<Record<string, unknown>>[],
310
340
  ): string | null {
@@ -351,7 +381,13 @@ function serializeBatchResult(
351
381
  };
352
382
  }
353
383
 
354
- return { slotId, ok: true, ...result.value };
384
+ const {
385
+ contextMode: _contextMode,
386
+ contextPackId: _contextPackId,
387
+ reuseLabels: _reuseLabels,
388
+ ...image
389
+ } = result.value;
390
+ return { slotId, ok: true, ...image };
355
391
  }
356
392
 
357
393
  function imageAssetId(value: Record<string, unknown>): string | undefined {
@@ -73,9 +73,10 @@ import {
73
73
  type StyleBrief,
74
74
  } from "../shared/api.js";
75
75
  import {
76
+ assetUrls,
76
77
  imageArtifactLinks,
77
78
  requireGenerationSessionInLibrary,
78
- serializeAsset,
79
+ serializeAssetSummary,
79
80
  } from "./_helpers.js";
80
81
  import { readImageModelDefault } from "./_image-model-default.js";
81
82
  import { resolveTemplateAccess } from "./_template-access.js";
@@ -127,7 +128,7 @@ const imageGenerationAgentInputSchema = z.object({
127
128
 
128
129
  export default defineAction({
129
130
  description:
130
- "Generate one brand-consistent image from a brand kit/library. This is synchronous for images and returns the final asset with preview/download/embed URLs. Use @brand-kit mentions as libraryId and @preset mentions as presetId when present. If no preset is tagged, call list-generation-presets first and use a matching preset's presetId; the user may not know presets exist. Generate presetless only when no preset matches the request. Use generate-image-batch for multiple independent slots; do not poll image runs after this action returns.",
131
+ "Generate one brand-consistent image from a brand kit/library. This is synchronous for images and returns a compact asset summary with preview/download/embed URLs; use get-asset for full asset details and get-audit-run for prompt, references, and settings. Use @brand-kit mentions as libraryId and @preset mentions as presetId when present. If no preset is tagged, call list-generation-presets first and use a matching preset's presetId; the user may not know presets exist. Generate presetless only when no preset matches the request. Use generate-image-batch for multiple independent slots; do not poll image runs after this action returns.",
131
132
  schema: z.object({
132
133
  libraryId: z
133
134
  .string()
@@ -1129,7 +1130,8 @@ export default defineAction({
1129
1130
  }),
1130
1131
  })
1131
1132
  .where(eq(schema.assetGenerationRuns.id, runId));
1132
- const serialized = serializeAsset(asset);
1133
+ const serialized = serializeAssetSummary(asset);
1134
+ const urls = assetUrls(asset);
1133
1135
  await upsertVariantSlot({
1134
1136
  runId,
1135
1137
  batchId: args.variantBatchId ?? null,
@@ -1144,12 +1146,11 @@ export default defineAction({
1144
1146
  status: "ready",
1145
1147
  assetId: asset.id,
1146
1148
  previewUrl: serialized.previewUrl,
1147
- thumbnailUrl: serialized.thumbnailUrl,
1149
+ thumbnailUrl: urls.thumbnailUrl,
1148
1150
  });
1149
1151
  return {
1150
1152
  ...serialized,
1151
1153
  runId,
1152
- artifactType: "image",
1153
1154
  // Cross-app callers embed the artifact in HTML. `url` is the Assets
1154
1155
  // detail page; previewUrl is the actual media response.
1155
1156
  Artifacts: imageArtifactLinks({
@@ -18,7 +18,7 @@ import {
18
18
  import {
19
19
  buildAssetLineage,
20
20
  requireLibrary,
21
- serializeAsset,
21
+ serializeAssetListItem,
22
22
  } from "./_helpers.js";
23
23
 
24
24
  export default defineAction({
@@ -99,7 +99,29 @@ export default defineAction({
99
99
  const candidateRunIdSet = new Set(candidateRunIds ?? []);
100
100
  const [rows, lineageRows] = await Promise.all([
101
101
  db
102
- .select()
102
+ .select({
103
+ id: schema.assets.id,
104
+ libraryId: schema.assets.libraryId,
105
+ collectionId: schema.assets.collectionId,
106
+ folderId: schema.assets.folderId,
107
+ mediaType: schema.assets.mediaType,
108
+ role: schema.assets.role,
109
+ status: schema.assets.status,
110
+ title: schema.assets.title,
111
+ description: schema.assets.description,
112
+ altText: schema.assets.altText,
113
+ prompt: schema.assets.prompt,
114
+ model: schema.assets.model,
115
+ aspectRatio: schema.assets.aspectRatio,
116
+ mimeType: schema.assets.mimeType,
117
+ width: schema.assets.width,
118
+ height: schema.assets.height,
119
+ durationSeconds: schema.assets.durationSeconds,
120
+ objectKey: schema.assets.objectKey,
121
+ thumbnailObjectKey: schema.assets.thumbnailObjectKey,
122
+ generationRunId: schema.assets.generationRunId,
123
+ metadata: schema.assets.metadata,
124
+ })
103
125
  .from(schema.assets)
104
126
  .where(and(...filters))
105
127
  .orderBy(desc(schema.assets.createdAt)),
@@ -148,7 +170,7 @@ export default defineAction({
148
170
  })
149
171
  .filter((asset) => assetMatchesSearch(asset, normalizedQuery, category))
150
172
  .map((asset) => ({
151
- ...serializeAsset(asset, lineageById.get(asset.id) ?? null),
173
+ ...serializeAssetListItem(asset, lineageById.get(asset.id) ?? null),
152
174
  libraryTitle: libraryTitleById.get(asset.libraryId) ?? null,
153
175
  }));
154
176
  return { count: assets.length, assets };
@@ -0,0 +1,6 @@
1
+ ---
2
+ type: improved
3
+ date: 2026-09-01
4
+ ---
5
+
6
+ Image generation returns compact results so multi-image runs stop hitting delegated result limits.
@@ -107,7 +107,8 @@ function buildDelegationMessage(request: AssetsImageRequest): string {
107
107
  `${references.join(", ")}\n`
108
108
  : "") +
109
109
  `\nPick the best matching library via match-library if no libraryId is ` +
110
- `obvious, then generate with generate-image-batch. Return assetId, runId, ` +
110
+ `obvious, then generate with generate-image-batch. Return id (the asset ` +
111
+ `ID), runId, ` +
111
112
  `previewUrl, and downloadUrl verbatim so the slides agent can drop them ` +
112
113
  `into the slide HTML. Set source: "a2a" and callerAppId: "slides" so the ` +
113
114
  `Assets audit log groups these generations.`
@@ -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
  }
@@ -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;
@@ -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: [
@@ -43,25 +43,25 @@ 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
- error?: undefined;
48
46
  ok?: undefined;
47
+ error?: undefined;
48
+ id?: undefined;
49
49
  } | {
50
50
  summary?: undefined;
51
51
  spans?: undefined;
52
52
  id: string;
53
- error?: undefined;
54
53
  ok?: undefined;
54
+ error?: undefined;
55
55
  } | {
56
56
  summary?: undefined;
57
57
  spans?: undefined;
58
- id?: undefined;
59
58
  error: any;
60
59
  ok?: undefined;
60
+ id?: 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
  }>>;
@@ -15,6 +15,6 @@ export declare function createProgressHandler(): import("h3").EventHandlerWithFe
15
15
  error: string;
16
16
  ok?: undefined;
17
17
  } | {
18
- ok: boolean;
19
18
  error?: undefined;
19
+ ok: boolean;
20
20
  }>>;
@@ -37,17 +37,17 @@ export declare function createWriteSecretHandler(): import("h3").EventHandlerWit
37
37
  ok?: undefined;
38
38
  status?: undefined;
39
39
  } | {
40
- error?: undefined;
41
40
  ok: boolean;
42
41
  status: string;
42
+ error?: undefined;
43
43
  } | {
44
44
  ok?: undefined;
45
45
  error: string;
46
46
  removed?: undefined;
47
47
  } | {
48
- error?: undefined;
49
48
  ok: boolean;
50
49
  removed: boolean;
50
+ error?: undefined;
51
51
  }>>;
52
52
  /**
53
53
  * POST /_agent-native/secrets/:key/test — validate an optional candidate value
@@ -58,13 +58,13 @@ export declare function createTestSecretHandler(): import("h3").EventHandlerWith
58
58
  error: string;
59
59
  note?: undefined;
60
60
  } | {
61
- error?: undefined;
62
61
  ok: boolean;
63
62
  note?: undefined;
64
- } | {
65
63
  error?: undefined;
64
+ } | {
66
65
  ok: boolean;
67
66
  note: string;
67
+ error?: undefined;
68
68
  } | {
69
69
  note?: undefined;
70
70
  ok: boolean;
@@ -95,11 +95,11 @@ export interface AdHocSecretPayload {
95
95
  export declare function createAdHocSecretHandler(): import("h3").EventHandlerWithFetch<import("h3").EventHandlerRequest, Promise<AdHocSecretPayload[] | {
96
96
  error: string;
97
97
  } | {
98
- error?: undefined;
99
98
  ok: boolean;
100
99
  key: string;
101
- } | {
102
100
  error?: undefined;
101
+ } | {
103
102
  ok: boolean;
104
103
  removed: boolean;
104
+ error?: undefined;
105
105
  }>>;
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-20260902103608",
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": {
@@ -437,8 +437,8 @@
437
437
  "y-protocols": "^1.0.7",
438
438
  "yjs": "^13.6.32",
439
439
  "zod": "^4.3.6",
440
- "@agent-native/recap-cli": "0.5.23",
441
- "@agent-native/toolkit": "^0.19.1"
440
+ "@agent-native/toolkit": "^0.19.1",
441
+ "@agent-native/recap-cli": "0.5.23"
442
442
  },
443
443
  "devDependencies": {
444
444
  "@ai-sdk/anthropic": "^3.0.71",