@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.
- package/README.md +1 -13
- package/corpus/README.md +1 -1
- package/corpus/core/CHANGELOG.md +6 -0
- package/corpus/core/package.json +1 -1
- package/corpus/core/src/agent/production-agent.ts +65 -1
- package/corpus/core/src/agent/run-store.ts +61 -1
- package/corpus/core/src/server/builder-design-systems.ts +1 -1
- package/corpus/templates/analytics/.agents/skills/dashboard-management/SKILL.md +15 -0
- package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/report-panel-window.ts +3 -3
- package/corpus/templates/analytics/changelog/2026-07-25-analytics-dashboards-and-scheduled-reports-are-faster-and-mo.md +6 -0
- package/corpus/templates/analytics/server/db/schema.ts +12 -0
- package/corpus/templates/analytics/server/lib/dashboard-panel-query.ts +8 -4
- package/corpus/templates/analytics/server/lib/dashboard-report.ts +136 -5
- package/corpus/templates/analytics/server/lib/dashboard-time-scope.ts +250 -1
- package/corpus/templates/analytics/server/lib/first-party-analytics-cache.ts +167 -0
- package/corpus/templates/analytics/server/lib/first-party-analytics.ts +34 -9
- package/corpus/templates/analytics/server/lib/first-party-dashboard-repair.ts +105 -23
- package/corpus/templates/analytics/server/lib/first-party-unbounded-panel-repair.ts +169 -0
- package/corpus/templates/analytics/server/plugins/db.ts +41 -1
- package/corpus/templates/analytics/shared/dashboard-report-timeouts.ts +6 -3
- package/corpus/templates/design/actions/edit-design.ts +66 -27
- package/corpus/templates/design/changelog/2026-07-25-design-edits-retry-concurrent-changes.md +6 -0
- package/corpus/templates/design/server/source-workspace.ts +5 -1
- package/corpus/templates/forms/.agents/skills/form-responses/SKILL.md +6 -4
- package/corpus/templates/forms/actions/create-form.ts +5 -1
- package/corpus/templates/forms/actions/export-responses.ts +22 -11
- package/corpus/templates/forms/actions/update-form.ts +5 -1
- package/corpus/templates/forms/changelog/2026-07-25-fields-can-be-created-without-ids.md +6 -0
- package/corpus/templates/forms/changelog/2026-07-25-response-exports-use-file-storage.md +6 -0
- package/corpus/templates/forms/server/lib/validate-fields.ts +43 -0
- package/corpus/templates/slides/actions/extract-pdf.ts +4 -4
- package/corpus/templates/slides/actions/import-file.ts +3 -4
- package/corpus/templates/slides/changelog/2026-07-25-gemini-image-models-updated.md +6 -0
- package/corpus/templates/slides/changelog/2026-07-25-pdf-imports-tolerate-missing-canvas.md +6 -0
- package/corpus/templates/slides/server/handlers/image-providers/gemini.ts +5 -2
- package/corpus/templates/slides/server/lib/pdf-parse-setup.ts +150 -0
- package/dist/agent/production-agent.d.ts.map +1 -1
- package/dist/agent/production-agent.js +63 -1
- package/dist/agent/production-agent.js.map +1 -1
- package/dist/agent/run-store.d.ts.map +1 -1
- package/dist/agent/run-store.js +49 -0
- package/dist/agent/run-store.js.map +1 -1
- package/dist/collab/awareness.d.ts +2 -2
- package/dist/collab/awareness.d.ts.map +1 -1
- package/dist/collab/routes.d.ts +1 -1
- package/dist/collab/struct-routes.d.ts +1 -1
- package/dist/notifications/routes.d.ts +3 -3
- package/dist/observability/routes.d.ts +1 -1
- package/dist/resources/handlers.d.ts +1 -1
- package/dist/secrets/routes.d.ts +9 -9
- package/dist/server/agent-engine-api-key-route.d.ts +1 -1
- package/dist/server/builder-design-systems.js +1 -1
- package/dist/server/builder-design-systems.js.map +1 -1
- package/package.json +3 -3
- package/src/agent/production-agent.ts +65 -1
- package/src/agent/run-store.ts +61 -1
- 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
|
|
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
package/corpus/core/CHANGELOG.md
CHANGED
package/corpus/core/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@agent-native/core",
|
|
3
|
-
"version": "0.122.
|
|
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 && !
|
|
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
|
-
|
|
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
|
|
@@ -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(
|
|
450
|
-
|
|
451
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|