@agent-native/core 0.122.1 → 0.122.2

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 (57) hide show
  1. package/README.md +1 -13
  2. package/corpus/README.md +1 -1
  3. package/corpus/core/CHANGELOG.md +6 -0
  4. package/corpus/core/package.json +1 -1
  5. package/corpus/core/src/agent/production-agent.ts +65 -1
  6. package/corpus/core/src/agent/run-store.ts +61 -1
  7. package/corpus/core/src/server/builder-design-systems.ts +1 -1
  8. package/corpus/templates/analytics/.agents/skills/dashboard-management/SKILL.md +15 -0
  9. package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/report-panel-window.ts +3 -3
  10. package/corpus/templates/analytics/changelog/2026-07-25-analytics-dashboards-and-scheduled-reports-are-faster-and-mo.md +6 -0
  11. package/corpus/templates/analytics/server/db/schema.ts +12 -0
  12. package/corpus/templates/analytics/server/lib/dashboard-panel-query.ts +8 -4
  13. package/corpus/templates/analytics/server/lib/dashboard-report.ts +136 -5
  14. package/corpus/templates/analytics/server/lib/dashboard-time-scope.ts +250 -1
  15. package/corpus/templates/analytics/server/lib/first-party-analytics-cache.ts +167 -0
  16. package/corpus/templates/analytics/server/lib/first-party-analytics.ts +34 -9
  17. package/corpus/templates/analytics/server/lib/first-party-dashboard-repair.ts +105 -23
  18. package/corpus/templates/analytics/server/lib/first-party-unbounded-panel-repair.ts +169 -0
  19. package/corpus/templates/analytics/server/plugins/db.ts +41 -1
  20. package/corpus/templates/analytics/shared/dashboard-report-timeouts.ts +6 -3
  21. package/corpus/templates/design/actions/edit-design.ts +66 -27
  22. package/corpus/templates/design/changelog/2026-07-25-design-edits-retry-concurrent-changes.md +6 -0
  23. package/corpus/templates/design/server/source-workspace.ts +5 -1
  24. package/corpus/templates/forms/.agents/skills/form-responses/SKILL.md +6 -4
  25. package/corpus/templates/forms/actions/create-form.ts +5 -1
  26. package/corpus/templates/forms/actions/export-responses.ts +22 -11
  27. package/corpus/templates/forms/actions/update-form.ts +5 -1
  28. package/corpus/templates/forms/changelog/2026-07-25-fields-can-be-created-without-ids.md +6 -0
  29. package/corpus/templates/forms/changelog/2026-07-25-response-exports-use-file-storage.md +6 -0
  30. package/corpus/templates/forms/server/lib/validate-fields.ts +43 -0
  31. package/corpus/templates/slides/actions/extract-pdf.ts +4 -4
  32. package/corpus/templates/slides/actions/import-file.ts +3 -4
  33. package/corpus/templates/slides/changelog/2026-07-25-gemini-image-models-updated.md +6 -0
  34. package/corpus/templates/slides/changelog/2026-07-25-pdf-imports-tolerate-missing-canvas.md +6 -0
  35. package/corpus/templates/slides/server/handlers/image-providers/gemini.ts +5 -2
  36. package/corpus/templates/slides/server/lib/pdf-parse-setup.ts +150 -0
  37. package/dist/agent/production-agent.d.ts.map +1 -1
  38. package/dist/agent/production-agent.js +63 -1
  39. package/dist/agent/production-agent.js.map +1 -1
  40. package/dist/agent/run-store.d.ts.map +1 -1
  41. package/dist/agent/run-store.js +49 -0
  42. package/dist/agent/run-store.js.map +1 -1
  43. package/dist/collab/awareness.d.ts +2 -2
  44. package/dist/collab/awareness.d.ts.map +1 -1
  45. package/dist/collab/routes.d.ts +1 -1
  46. package/dist/collab/struct-routes.d.ts +1 -1
  47. package/dist/notifications/routes.d.ts +3 -3
  48. package/dist/observability/routes.d.ts +1 -1
  49. package/dist/resources/handlers.d.ts +1 -1
  50. package/dist/secrets/routes.d.ts +9 -9
  51. package/dist/server/agent-engine-api-key-route.d.ts +1 -1
  52. package/dist/server/builder-design-systems.js +1 -1
  53. package/dist/server/builder-design-systems.js.map +1 -1
  54. package/package.json +3 -3
  55. package/src/agent/production-agent.ts +65 -1
  56. package/src/agent/run-store.ts +61 -1
  57. package/src/server/builder-design-systems.ts +1 -1
package/README.md CHANGED
@@ -39,8 +39,6 @@ Fork a working app and let the agent evolve it. **You can customize everything.*
39
39
 
40
40
  **Agent-Native Loom**
41
41
 
42
- Record your screen with auto-transcripts and captured browser debug logs, share a link, and let an agent read the transcript, see timestamped frames, and fix the bug.
43
-
44
42
  </td>
45
43
  <td width="33%" align="center" valign="top">
46
44
 
@@ -50,8 +48,6 @@ Record your screen with auto-transcripts and captured browser debug logs, share
50
48
 
51
49
  **Visual plan mode for coding agents**
52
50
 
53
- Install `/visual-plan` and `/visual-recap` so your coding agent can plan before it builds and recap changes after they land. High-level code reviews with diagrams, wireframes, annotations, and review links.
54
-
55
51
  </td>
56
52
  <td width="33%" align="center" valign="top">
57
53
 
@@ -61,8 +57,6 @@ Install `/visual-plan` and `/visual-recap` so your coding agent can plan before
61
57
 
62
58
  **Agent-Native Figma**
63
59
 
64
- Generate interactive HTML prototypes, compare variants, refine controls, and export the result.
65
-
66
60
  </td>
67
61
  </tr>
68
62
  <tr>
@@ -74,8 +68,6 @@ Generate interactive HTML prototypes, compare variants, refine controls, and exp
74
68
 
75
69
  **Agent-Native Notion/Obsidian**
76
70
 
77
- Edit local Markdown/MDX files, generate rich interactive custom blocks, and draft, rewrite, or publish with an agent.
78
-
79
71
  </td>
80
72
  <td width="33%" align="center" valign="top">
81
73
 
@@ -85,8 +77,6 @@ Edit local Markdown/MDX files, generate rich interactive custom blocks, and draf
85
77
 
86
78
  **Open-Source Alternative to Amplitude and FullStory**
87
79
 
88
- Connect analytics data sources, prompt for real charts, and build reusable dashboards.
89
-
90
80
  </td>
91
81
  <td width="33%" align="center" valign="top">
92
82
 
@@ -96,13 +86,11 @@ Connect analytics data sources, prompt for real charts, and build reusable dashb
96
86
 
97
87
  **A minimal ChatGPT-style app for your own agent**
98
88
 
99
- Chat-first app scaffold with durable threads, actions, auth, live sync, and a clean path to add screens or plug in your own agent backend.
100
-
101
89
  </td>
102
90
  </tr>
103
91
  </table>
104
92
 
105
- View the full app gallery at **[agent-native.com/apps](https://agent-native.com/apps)**, or build from scratch with the **[framework guide](https://agent-native.com/docs/getting-started)**.
93
+ View the [full gallery](https://agent-native.com/apps), or build from scratch with the **[framework guide](https://agent-native.com/docs/getting-started)**.
106
94
 
107
95
  ## Quick Start
108
96
 
package/corpus/README.md CHANGED
@@ -30,4 +30,4 @@ rg -n "defineAction|useActionQuery" node_modules/@agent-native/core/corpus
30
30
 
31
31
  - core files: 1671
32
32
  - toolkit files: 160
33
- - template files: 6461
33
+ - template files: 6470
@@ -1,5 +1,11 @@
1
1
  # @agent-native/core
2
2
 
3
+ ## 0.122.2
4
+
5
+ ### Patch Changes
6
+
7
+ - 905bdba: Improve tool-input recovery, stale-run handling, and design-system routing reliability.
8
+
3
9
  ## 0.122.1
4
10
 
5
11
  ### Patch Changes
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agent-native/core",
3
- "version": "0.122.1",
3
+ "version": "0.122.2",
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": {
@@ -3369,6 +3369,59 @@ function normalizeOptionalToolPlaceholders(
3369
3369
  return { input: normalized, changed: true };
3370
3370
  }
3371
3371
 
3372
+ /**
3373
+ * Models routinely JSON-encode a field's value into a string when the tool
3374
+ * schema wants an object/array — e.g. `config: "{\"name\":...}"` instead of
3375
+ * `config: {name:...}`, or `operations: "[...]"` instead of `operations: [...]`.
3376
+ * Seen across every app profiled in the 2026-07-25 reliability sweep
3377
+ * (brain's `update-source` config, analytics' `mutate-dashboard`/
3378
+ * `update-dashboard` operations/ops/config) and the model does NOT
3379
+ * self-correct across repeated identical retries — it burns the full
3380
+ * `MAX_IDENTICAL_TOOL_ERRORS` retry budget on the same wrong shape every
3381
+ * time. Evidence-gated exactly like `normalizeOptionalToolPlaceholders`:
3382
+ * only coerce a field whose CURRENT value fails schema validation (so a
3383
+ * legitimate string value is never touched — a field schema-valid as a
3384
+ * string is never also schema-valid as object/array) and whose parsed form
3385
+ * passes. Never touches values that are already the right shape.
3386
+ */
3387
+ function coerceStringifiedJsonToolValues(
3388
+ schema: RawJsonSchema | undefined,
3389
+ input: unknown,
3390
+ ): { input: unknown; changed: boolean } {
3391
+ if (!schema?.properties || !input || typeof input !== "object") {
3392
+ return { input, changed: false };
3393
+ }
3394
+ if (Array.isArray(input)) return { input, changed: false };
3395
+
3396
+ let normalized: Record<string, unknown> | null = null;
3397
+ for (const [key, value] of Object.entries(input as Record<string, unknown>)) {
3398
+ if (typeof value !== "string" || value.trim().length === 0) continue;
3399
+ const propertySchema = schema.properties[key];
3400
+ if (!propertySchema || typeof propertySchema !== "object") continue;
3401
+ const expectedType = (propertySchema as { type?: unknown }).type;
3402
+ const expectsObjectOrArray =
3403
+ expectedType === "object" ||
3404
+ expectedType === "array" ||
3405
+ (Array.isArray(expectedType) &&
3406
+ (expectedType.includes("object") || expectedType.includes("array")));
3407
+ if (!expectsObjectOrArray) continue;
3408
+ if (schemaAcceptsToolValue(propertySchema, value)) continue;
3409
+ let parsed: unknown;
3410
+ try {
3411
+ parsed = JSON.parse(value);
3412
+ } catch {
3413
+ continue;
3414
+ }
3415
+ if (typeof parsed !== "object" || parsed === null) continue;
3416
+ if (!schemaAcceptsToolValue(propertySchema, parsed)) continue;
3417
+ normalized ??= { ...(input as Record<string, unknown>) };
3418
+ normalized[key] = parsed;
3419
+ }
3420
+ return normalized
3421
+ ? { input: normalized, changed: true }
3422
+ : { input, changed: false };
3423
+ }
3424
+
3372
3425
  function getRawToolInputValidator(schema: RawJsonSchema): ValidateFunction {
3373
3426
  const cached = rawToolInputValidatorCache.get(schema);
3374
3427
  if (cached) return cached;
@@ -4354,6 +4407,17 @@ export async function runAgentLoop(opts: {
4354
4407
  if (placeholderNormalization.changed) {
4355
4408
  toolCall = { ...toolCall, input: placeholderNormalization.input };
4356
4409
  }
4410
+ const jsonStringCoercion = actionEntry
4411
+ ? coerceStringifiedJsonToolValues(
4412
+ actionEntry.tool.parameters,
4413
+ toolCall.input,
4414
+ )
4415
+ : { input: toolCall.input, changed: false };
4416
+ if (jsonStringCoercion.changed) {
4417
+ toolCall = { ...toolCall, input: jsonStringCoercion.input };
4418
+ }
4419
+ const toolInputNormalized =
4420
+ placeholderNormalization.changed || jsonStringCoercion.changed;
4357
4421
  const wireToolInput = JSON.stringify(toolCall.input ?? {});
4358
4422
  const normalizedToolInput = normalizeToolCallInputForHistory(
4359
4423
  toolCall.input,
@@ -4776,7 +4840,7 @@ export async function runAgentLoop(opts: {
4776
4840
  });
4777
4841
 
4778
4842
  const toolCallSchemaError = toolCallErrors.get(toolCall.id);
4779
- if (toolCallSchemaError && !placeholderNormalization.changed) {
4843
+ if (toolCallSchemaError && !toolInputNormalized) {
4780
4844
  const result = finalizeToolErrorResult(
4781
4845
  toolInputSchemaErrorResult(
4782
4846
  toolCall.name,
@@ -173,6 +173,34 @@ export const UNCLAIMED_BACKGROUND_RUN_FAST_SWEEP_MS = 20_000;
173
173
  */
174
174
  const STALE_RUN_RECOVERY_MAX_TURN_RUNS = 25;
175
175
 
176
+ /**
177
+ * Circuit breaker for a DETERMINISTIC dead-on-arrival loop: some request
178
+ * shapes make the worker hang almost immediately every single time (e.g. an
179
+ * un-timed-out provider fetch that blocks the event loop) rather than merely
180
+ * hitting a transient blip. Because `attemptStaleRunRecovery` replays the
181
+ * SAME captured `dispatch_payload` on every successor (never a fresh
182
+ * request), such a turn was retrying an unwinnable request up to
183
+ * `STALE_RUN_RECOVERY_MAX_TURN_RUNS` (25) times — ~25 * 53s ≈ 22 minutes,
184
+ * each cycle re-billing the full input context — before finally giving up.
185
+ * Confirmed live in prod (assets: one turn cycled 24x, each attempt an
186
+ * identical ~32K-token request that made a token of real progress around
187
+ * ~8s in then went completely silent for the rest of its life until the 45s
188
+ * reap). Stop recovering after this many CONSECUTIVE stale_run reaps that
189
+ * each made near-zero real progress — a single blip never trips it (needs
190
+ * 3 in a row), and a run that's genuinely grinding through long work right
191
+ * up to its heartbeat window is untouched (see `hasNoForwardProgress`).
192
+ */
193
+ const STALE_RUN_RECOVERY_CONSECUTIVE_NO_PROGRESS_LIMIT = 3;
194
+
195
+ /**
196
+ * A reaped run counts as having made no forward progress if it never
197
+ * emitted a real event (`last_progress_at` unset) or died within this many
198
+ * ms of starting — well short of the 45s background reap window, so a run
199
+ * that was legitimately working almost up to the reap boundary is not
200
+ * penalized. See `STALE_RUN_RECOVERY_CONSECUTIVE_NO_PROGRESS_LIMIT`.
201
+ */
202
+ const STALE_RUN_RECOVERY_NO_PROGRESS_WINDOW_MS = 20_000;
203
+
176
204
  /**
177
205
  * Maximum time the stale reapers (`reapIfStale`, `reapAllStaleRuns`,
178
206
  * `cleanupOldRuns`'s heartbeat-stale pass) will suspend reaping a "running"
@@ -1361,7 +1389,8 @@ type StaleRunRecoveryOutcome =
1361
1389
  | { outcome: "not_background" }
1362
1390
  | { outcome: "payload_missing" }
1363
1391
  | { outcome: "newer_run_exists" }
1364
- | { outcome: "budget_exhausted" };
1392
+ | { outcome: "budget_exhausted" }
1393
+ | { outcome: "repeated_no_progress" };
1365
1394
 
1366
1395
  /**
1367
1396
  * Mirrors `production-agent.ts`'s `generateRunId` — duplicated (not
@@ -1477,6 +1506,37 @@ async function attemptStaleRunRecovery(
1477
1506
  return { outcome: "budget_exhausted" };
1478
1507
  }
1479
1508
 
1509
+ // See `STALE_RUN_RECOVERY_CONSECUTIVE_NO_PROGRESS_LIMIT`: a run whose last
1510
+ // N attempts (including the one just reaped, already written by the
1511
+ // caller's UPDATE earlier in this same transaction) all died as stale_run
1512
+ // having made essentially no real progress is retrying an unwinnable
1513
+ // request, not recovering from a blip. Stop far short of the 25-run/~22min
1514
+ // budget above instead of grinding through it.
1515
+ const { rows: recentRows } = await db.execute({
1516
+ sql: `SELECT error_code, started_at, last_progress_at FROM agent_runs WHERE turn_id = ? ORDER BY started_at DESC LIMIT ?`,
1517
+ args: [turnId, STALE_RUN_RECOVERY_CONSECUTIVE_NO_PROGRESS_LIMIT],
1518
+ });
1519
+ const recent = (recentRows ?? []) as Array<{
1520
+ error_code?: string | null;
1521
+ started_at: number | string;
1522
+ last_progress_at: number | string | null;
1523
+ }>;
1524
+ const allDeadOnArrival =
1525
+ recent.length === STALE_RUN_RECOVERY_CONSECUTIVE_NO_PROGRESS_LIMIT &&
1526
+ recent.every((r) => {
1527
+ if (r.error_code !== STALE_RUN_ERROR_EVENT.errorCode) return false;
1528
+ const started = Number(r.started_at) || 0;
1529
+ const progress =
1530
+ r.last_progress_at == null ? null : Number(r.last_progress_at);
1531
+ return (
1532
+ progress === null ||
1533
+ progress - started < STALE_RUN_RECOVERY_NO_PROGRESS_WINDOW_MS
1534
+ );
1535
+ });
1536
+ if (allDeadOnArrival) {
1537
+ return { outcome: "repeated_no_progress" };
1538
+ }
1539
+
1480
1540
  const successorRunId = generateRecoveryRunId();
1481
1541
  const now = Date.now();
1482
1542
  await db.execute({
@@ -140,7 +140,7 @@ function trimTrailingSlash(value: string): string {
140
140
  export function getBuilderDesignSystemsBaseUrl(): string {
141
141
  return (
142
142
  process.env.BUILDER_DESIGN_SYSTEMS_BASE_URL ||
143
- `${trimTrailingSlash(getBuilderProxyOrigin())}/design-systems/v1`
143
+ `${trimTrailingSlash(getBuilderProxyOrigin())}/agent-native/design-systems/v1`
144
144
  );
145
145
  }
146
146
 
@@ -84,6 +84,21 @@ Do not rely on undeclared time variables. Server validation rejects unbound
84
84
  first-party SQL, so declare the filter or choose an explicit non-dashboard
85
85
  scope before saving.
86
86
 
87
+ **A bound anywhere in the SQL is not the same as every CTE having its own
88
+ bound.** If a panel has multiple top-level CTEs (`WITH a AS (...), b AS
89
+ (...)`) and more than one of them reads `analytics_events`, EVERY one of
90
+ those CTEs needs its own `{{timeRange}}`/`{{<id>Start}}`/`{{<id>End}}`
91
+ reference or literal date bound — not just the final `SELECT` or one sibling
92
+ CTE. A CTE that computes something like "this user's first-ever active day"
93
+ by scanning `analytics_events` with no bound at all will full-table-scan on
94
+ every render even though the panel *looks* time-bound overall (root cause of
95
+ a 2026-07-25 production incident: several dashboards had exactly this shape).
96
+ Server validation checks each top-level CTE independently now, so this fails
97
+ at save time — but write it right the first time: bound every CTE, or use
98
+ `config.timeScope: "cohort-history"` only for a CTE that is genuinely
99
+ defining a cohort (e.g. a first-seen date), never as a way to skip bounding
100
+ an ordinary activity scan.
101
+
87
102
  ## Creating A Dashboard
88
103
 
89
104
  When the user asks for a dashboard:
@@ -1,8 +1,8 @@
1
- import { MAX_CONCURRENT_SQL_QUERIES } from "@shared/sql-query-limits";
2
-
3
1
  import type { SqlPanel } from "./types";
4
2
 
5
- export const REPORT_PANEL_CHUNK_SIZE = MAX_CONCURRENT_SQL_QUERIES;
3
+ // Keep each report window within the browser-side SQL query concurrency limit
4
+ // so a cold window does not serialize into multiple waves before readiness.
5
+ export const REPORT_PANEL_CHUNK_SIZE = 4;
6
6
 
7
7
  export function listReportablePanelIds(panels: SqlPanel[]): string[] {
8
8
  return panels
@@ -0,0 +1,6 @@
1
+ ---
2
+ type: improved
3
+ date: 2026-07-25
4
+ ---
5
+
6
+ Analytics dashboards and scheduled reports are faster and more reliable with large event histories.
@@ -193,6 +193,18 @@ export const bigqueryCache = table("bigquery_cache", {
193
193
  expiresAt: text("expires_at").notNull(),
194
194
  });
195
195
 
196
+ /**
197
+ * First-party dashboard panel result cache — see
198
+ * server/lib/first-party-analytics-cache.ts.
199
+ */
200
+ export const firstPartyAnalyticsCache = table("first_party_analytics_cache", {
201
+ key: text("key").primaryKey(),
202
+ sql: text("sql").notNull(),
203
+ result: text("result").notNull(),
204
+ createdAt: text("created_at").notNull(),
205
+ expiresAt: text("expires_at").notNull(),
206
+ });
207
+
196
208
  /**
197
209
  * Public write keys for the first-party analytics ingestion endpoint.
198
210
  * The key is intentionally public/write-only: it can create events for the
@@ -446,10 +446,14 @@ export async function runDashboardPanelQuery(args: {
446
446
  }
447
447
 
448
448
  if (source === "first-party") {
449
- return await queryFirstPartyAnalytics(query, {
450
- userEmail: ctx.userEmail,
451
- orgId: ctx.orgId ?? null,
452
- });
449
+ return await queryFirstPartyAnalytics(
450
+ query,
451
+ {
452
+ userEmail: ctx.userEmail,
453
+ orgId: ctx.orgId ?? null,
454
+ },
455
+ { cache: true },
456
+ );
453
457
  }
454
458
 
455
459
  if (source === "demo") {
@@ -15,6 +15,8 @@ import {
15
15
  EMBED_TOKEN_QUERY_PARAM,
16
16
  } from "@agent-native/core/shared";
17
17
 
18
+ import { interpolate } from "../../app/pages/adhoc/sql-dashboard/interpolate";
19
+ import { serializePanelSql } from "../../app/pages/adhoc/sql-dashboard/panel-sql";
18
20
  import {
19
21
  listReportablePanelIds,
20
22
  REPORT_PANEL_CHUNK_SIZE,
@@ -32,6 +34,7 @@ import {
32
34
  type DashboardReportCaptureOutcome,
33
35
  type DashboardReportSubscription,
34
36
  } from "./dashboard-report-subscriptions";
37
+ import { queryFirstPartyAnalytics } from "./first-party-analytics.js";
35
38
 
36
39
  type ReportSnapshot = {
37
40
  dashboardId: string;
@@ -42,6 +45,8 @@ type ReportSnapshot = {
42
45
  reportSettingsUrl: string;
43
46
  generatedAt: string;
44
47
  panelIds: string[];
48
+ panels: SqlDashboardConfig["panels"];
49
+ variables?: Record<string, string>;
45
50
  };
46
51
 
47
52
  const DATE_FILTER_TYPES: ReadonlySet<FilterType> = new Set([
@@ -58,7 +63,9 @@ const LOCAL_SCREENSHOT_TIMEOUT_MS = 90_000;
58
63
  const SERVERLESS_SCREENSHOT_TIMEOUT_MS = 90_000;
59
64
  const SERVERLESS_SECOND_READY_TIMEOUT_MS = 45_000;
60
65
  // Cap browser work separately from the sweep-level delivery deadline.
61
- const SERVERLESS_CHUNKED_ATTEMPT_TIMEOUT_MS = 150_000;
66
+ // Reserve time under Netlify's 300s background-function limit for cleanup and
67
+ // email delivery (see MAX_CAPTURE_CLEANUP_RESERVE_MS / delivery reserves below).
68
+ const SERVERLESS_CHUNKED_ATTEMPT_TIMEOUT_MS = 210_000;
62
69
  const BROWSER_CLEANUP_TIMEOUT_MS = 10_000;
63
70
  const DASHBOARD_REPORT_EMAIL_TIMEOUT_MS = 10_000;
64
71
  const DASHBOARD_REPORT_EMAIL_OVERHEAD_RESERVE_MS = 5_000;
@@ -226,6 +233,8 @@ async function collectReportSnapshot(
226
233
  }),
227
234
  generatedAt: new Date().toISOString(),
228
235
  panelIds: listReportablePanelIds(config.panels),
236
+ panels: config.panels,
237
+ variables: config.variables,
229
238
  };
230
239
  }
231
240
 
@@ -375,6 +384,14 @@ function boundedStageTimeout(capMs: number, deadlineAt?: number): number {
375
384
  return Math.max(1, Math.min(capMs, remainingMs));
376
385
  }
377
386
 
387
+ function reportDeliveryReserveMs(recipientCount: number): number {
388
+ return (
389
+ MAX_CAPTURE_CLEANUP_RESERVE_MS +
390
+ recipientCount * DASHBOARD_REPORT_EMAIL_TIMEOUT_MS +
391
+ DASHBOARD_REPORT_EMAIL_OVERHEAD_RESERVE_MS
392
+ );
393
+ }
394
+
378
395
  async function runWithinCaptureDeadline<T>(
379
396
  operation: () => Promise<T>,
380
397
  deadlineAt?: number,
@@ -448,7 +465,15 @@ async function waitForDashboardReportReady(
448
465
  if (root.getAttribute("data-dashboard-report-ready") !== "true") {
449
466
  return false;
450
467
  }
451
- return !root.querySelector("[data-dashboard-report-loading='true']");
468
+ if (root.querySelector("[data-dashboard-report-loading='true']")) {
469
+ return false;
470
+ }
471
+ // A panel that errored (including its own query-call timeout) clears
472
+ // its loading marker just like a successful panel, so without this
473
+ // check the capture would treat a broken panel as "ready" and bake
474
+ // its raw error text into the delivered screenshot instead of
475
+ // failing the attempt so the retry sweep can pick it up.
476
+ return !root.querySelector("[role='alert']");
452
477
  })()`,
453
478
  undefined,
454
479
  { timeout },
@@ -505,6 +530,101 @@ async function waitForDashboardReportReady(
505
530
  }
506
531
  }
507
532
 
533
+ const PREWARM_CONCURRENCY = 3;
534
+ const PREWARM_PANEL_TIMEOUT_MS = 20_000;
535
+ const PREWARM_MAX_BUDGET_MS = 45_000;
536
+
537
+ /**
538
+ * Runs first-party panel queries once, directly, before the browser capture
539
+ * starts, so the capture's own query-dashboard-panel calls hit a warm cache
540
+ * instead of several panels racing each other cold for database time inside
541
+ * a single capture window — that contention, not any one query alone, is
542
+ * what was pushing panels past their capture timeout. Best-effort: a panel
543
+ * that fails or times out here is simply computed cold by the capture later,
544
+ * exactly as it always was, so this can never make a report worse than
545
+ * before it existed. Bounded to time left after reserving the full chunked
546
+ * capture attempt and delivery tail, so prewarming cannot starve capture.
547
+ * The per-panel timeout only bounds how long a worker waits before logging;
548
+ * it is also passed to the database/cache layers, and the original query is
549
+ * still awaited before this function returns so it cannot overlap capture.
550
+ */
551
+ async function prewarmFirstPartyPanelCache(
552
+ sub: DashboardReportSubscription,
553
+ snapshot: ReportSnapshot,
554
+ deadlineAt?: number,
555
+ recipientCount = 0,
556
+ ): Promise<void> {
557
+ const panels = (snapshot.panels ?? []).filter(
558
+ (panel) => panel.source === "first-party" && panel.sql != null,
559
+ );
560
+ if (!panels.length) return;
561
+
562
+ const remaining = deadlineAt ? deadlineAt - Date.now() : Infinity;
563
+ const budgetMs = Math.max(
564
+ 0,
565
+ Math.min(
566
+ PREWARM_MAX_BUDGET_MS,
567
+ deadlineAt
568
+ ? remaining -
569
+ SERVERLESS_CHUNKED_ATTEMPT_TIMEOUT_MS -
570
+ reportDeliveryReserveMs(recipientCount)
571
+ : PREWARM_MAX_BUDGET_MS,
572
+ ),
573
+ );
574
+ if (budgetMs <= 0) return;
575
+ const prewarmDeadline = Date.now() + budgetMs;
576
+
577
+ const vars: Record<string, string> = { ...snapshot.variables };
578
+ for (const [key, value] of Object.entries(snapshot.filters)) {
579
+ if (key.startsWith("f_")) vars[key.slice(2)] = value;
580
+ }
581
+ const scope = { userEmail: sub.ownerEmail, orgId: sub.orgId ?? null };
582
+
583
+ let index = 0;
584
+ async function worker(): Promise<void> {
585
+ while (index < panels.length && Date.now() < prewarmDeadline) {
586
+ const panel = panels[index++];
587
+ const sql = interpolate(serializePanelSql(panel.sql), vars, {
588
+ failClosedTimeVariables: true,
589
+ });
590
+ if (!sql.trim()) continue;
591
+ const panelTimeoutMs = Math.max(
592
+ 1,
593
+ Math.min(PREWARM_PANEL_TIMEOUT_MS, prewarmDeadline - Date.now()),
594
+ );
595
+ const query = queryFirstPartyAnalytics(sql, scope, {
596
+ cache: true,
597
+ timeoutMs: panelTimeoutMs,
598
+ });
599
+ let timeout: ReturnType<typeof setTimeout> | undefined;
600
+ try {
601
+ await Promise.race([
602
+ query,
603
+ new Promise<never>((_, reject) => {
604
+ timeout = setTimeout(
605
+ () => reject(new Error("prewarm panel timeout")),
606
+ panelTimeoutMs,
607
+ );
608
+ timeout.unref?.();
609
+ }),
610
+ ]);
611
+ } catch (err) {
612
+ console.warn(
613
+ `[dashboard-report] prewarm failed for panel ${panel.id}:`,
614
+ err instanceof Error ? err.message : err,
615
+ );
616
+ } finally {
617
+ if (timeout) clearTimeout(timeout);
618
+ await query.catch(() => undefined);
619
+ }
620
+ }
621
+ }
622
+
623
+ await Promise.all(
624
+ Array.from({ length: PREWARM_CONCURRENCY }, () => worker()),
625
+ );
626
+ }
627
+
508
628
  async function assertDashboardReportPanelWindow(
509
629
  page: any,
510
630
  expectedPanelIds: string[],
@@ -1358,14 +1478,25 @@ export async function sendDashboardReportSubscription(
1358
1478
  () => collectReportSnapshot(sub),
1359
1479
  options.deadlineAt,
1360
1480
  );
1481
+ try {
1482
+ await prewarmFirstPartyPanelCache(
1483
+ sub,
1484
+ snapshot,
1485
+ options.deadlineAt,
1486
+ recipients.length,
1487
+ );
1488
+ } catch (err) {
1489
+ console.warn(
1490
+ "[dashboard-report] panel cache prewarm failed (non-fatal):",
1491
+ err instanceof Error ? err.message : err,
1492
+ );
1493
+ }
1361
1494
  const captureTimeoutMs = options.deadlineAt
1362
1495
  ? Math.min(
1363
1496
  SERVERLESS_CHUNKED_ATTEMPT_TIMEOUT_MS,
1364
1497
  options.deadlineAt -
1365
1498
  Date.now() -
1366
- MAX_CAPTURE_CLEANUP_RESERVE_MS -
1367
- recipients.length * DASHBOARD_REPORT_EMAIL_TIMEOUT_MS -
1368
- DASHBOARD_REPORT_EMAIL_OVERHEAD_RESERVE_MS,
1499
+ reportDeliveryReserveMs(recipients.length),
1369
1500
  )
1370
1501
  : undefined;
1371
1502
  const capture = await captureDashboardPngWithFallback(