@agent-native/core 0.77.16 → 0.77.17

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 (60) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/core/CHANGELOG.md +8 -0
  3. package/corpus/core/package.json +1 -1
  4. package/corpus/core/src/client/AgentPanel.tsx +4 -0
  5. package/corpus/core/src/client/org/OrgSwitcher.tsx +4 -0
  6. package/corpus/core/src/client/use-db-sync.ts +19 -2
  7. package/corpus/core/src/server/auth-marketing.ts +15 -0
  8. package/corpus/core/src/server/onboarding-html.ts +47 -1
  9. package/corpus/templates/analytics/.agents/skills/dashboard-management/SKILL.md +76 -0
  10. package/corpus/templates/analytics/actions/update-dashboard.ts +1 -0
  11. package/corpus/templates/analytics/app/components/dashboard/SqlChart.tsx +13 -2
  12. package/corpus/templates/analytics/app/i18n-data.ts +10 -0
  13. package/corpus/templates/analytics/app/lib/sql-query.ts +97 -21
  14. package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/SqlChartCard.tsx +22 -1
  15. package/corpus/templates/analytics/app/root.tsx +15 -6
  16. package/corpus/templates/analytics/changelog/2026-06-25-dashboard-charts-no-longer-refresh-in-the-background-during-.md +6 -0
  17. package/corpus/templates/analytics/changelog/2026-06-25-first-party-dashboards-use-indexed-event-dates-for-faster-da.md +6 -0
  18. package/corpus/templates/analytics/changelog/2026-06-25-retention-and-active-user-dashboard-panels-now-count-account.md +6 -0
  19. package/corpus/templates/analytics/seeds/dashboards/agent-native-templates-first-party.json +58 -58
  20. package/corpus/templates/analytics/server/db/schema.ts +2 -0
  21. package/corpus/templates/analytics/server/handlers/sql-query.ts +1 -1
  22. package/corpus/templates/analytics/server/lib/dashboard-catalog.ts +1 -1
  23. package/corpus/templates/analytics/server/lib/first-party-analytics.ts +16 -6
  24. package/corpus/templates/analytics/server/lib/first-party-metric-catalog.ts +88 -56
  25. package/corpus/templates/analytics/server/plugins/db.ts +73 -0
  26. package/corpus/templates/clips/AGENTS.md +5 -0
  27. package/corpus/templates/clips/changelog/2026-06-25-github-issue-and-pull-request-pages-can-now-preview-playable.md +6 -0
  28. package/corpus/templates/clips/chrome-extension/PERMISSIONS.md +8 -2
  29. package/corpus/templates/clips/chrome-extension/public/manifest.json +14 -2
  30. package/corpus/templates/clips/chrome-extension/src/github-preview-content.ts +233 -0
  31. package/corpus/templates/clips/chrome-extension/src/github-preview.html +12 -0
  32. package/corpus/templates/clips/chrome-extension/src/github-preview.ts +414 -0
  33. package/corpus/templates/clips/chrome-extension/vite.config.ts +5 -0
  34. package/corpus/templates/mail/actions/archive-email.ts +13 -3
  35. package/corpus/templates/mail/app/hooks/use-emails.ts +5 -2
  36. package/corpus/templates/mail/changelog/2026-06-25-archive-failures-now-explain-when-gmail-needs-reconnecting-p.md +6 -0
  37. package/corpus/templates/mail/shared/archive-errors.ts +137 -0
  38. package/corpus/templates/plan/changelog/2026-06-25-hosted-signup-now-shows-how-to-switch-visual-plan-to-local-files.md +6 -0
  39. package/dist/client/AgentPanel.d.ts.map +1 -1
  40. package/dist/client/AgentPanel.js +2 -1
  41. package/dist/client/AgentPanel.js.map +1 -1
  42. package/dist/client/org/OrgSwitcher.d.ts.map +1 -1
  43. package/dist/client/org/OrgSwitcher.js +3 -1
  44. package/dist/client/org/OrgSwitcher.js.map +1 -1
  45. package/dist/client/use-db-sync.d.ts +9 -0
  46. package/dist/client/use-db-sync.d.ts.map +1 -1
  47. package/dist/client/use-db-sync.js +8 -1
  48. package/dist/client/use-db-sync.js.map +1 -1
  49. package/dist/collab/awareness.d.ts +2 -2
  50. package/dist/collab/awareness.d.ts.map +1 -1
  51. package/dist/observability/routes.d.ts +5 -5
  52. package/dist/server/agent-engine-api-key-route.d.ts +2 -2
  53. package/dist/server/auth-marketing.d.ts +4 -0
  54. package/dist/server/auth-marketing.d.ts.map +1 -1
  55. package/dist/server/auth-marketing.js +9 -0
  56. package/dist/server/auth-marketing.js.map +1 -1
  57. package/dist/server/onboarding-html.d.ts.map +1 -1
  58. package/dist/server/onboarding-html.js +46 -1
  59. package/dist/server/onboarding-html.js.map +1 -1
  60. package/package.json +1 -1
package/corpus/README.md CHANGED
@@ -28,4 +28,4 @@ rg -n "defineAction|useActionQuery" node_modules/@agent-native/core/corpus
28
28
  ## Generated Counts
29
29
 
30
30
  - core files: 1933
31
- - template files: 4302
31
+ - template files: 4312
@@ -1,5 +1,13 @@
1
1
  # @agent-native/core
2
2
 
3
+ ## 0.77.17
4
+
5
+ ### Patch Changes
6
+
7
+ - 6c65e7f: Show a Plan signup note for switching `/visual-plan` to local-files mode.
8
+ - 6c65e7f: Allow apps to filter the broad action-event query invalidation from `useDbSync`.
9
+ - 6c65e7f: Keep standalone scaffold installs stable when fresh Sentry transitive packages are published.
10
+
3
11
  ## 0.77.16
4
12
 
5
13
  ### Patch Changes
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agent-native/core",
3
- "version": "0.77.16",
3
+ "version": "0.77.17",
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": {
@@ -687,6 +687,7 @@ function AgentPanelInner({
687
687
  );
688
688
  const closeTabHint = isMac ? "\u2303W" : "Alt+W";
689
689
  const closeAllTabsHint = isMac ? "\u2303\u2325W" : "Ctrl+Alt+W";
690
+ const toggleSidebarHint = isMac ? "\u2318\\" : "Ctrl+\\";
690
691
 
691
692
  const [execMode, setExecMode] = useState<ExecMode>(() => {
692
693
  try {
@@ -1125,6 +1126,9 @@ function AgentPanelInner({
1125
1126
  className="shrink-0"
1126
1127
  />
1127
1128
  {t("agentPanel.collapseSidebar")}
1129
+ <DropdownMenuShortcut>
1130
+ {toggleSidebarHint}
1131
+ </DropdownMenuShortcut>
1128
1132
  </DropdownMenuItem>
1129
1133
  <DropdownMenuSeparator />
1130
1134
  </>
@@ -15,6 +15,7 @@ import {
15
15
  IconCode,
16
16
  IconContract,
17
17
  IconFileText,
18
+ IconLayoutBoard,
18
19
  IconLoader2,
19
20
  IconLogout,
20
21
  IconMail,
@@ -25,6 +26,7 @@ import {
25
26
  IconPhoto,
26
27
  IconPlus,
27
28
  IconPresentation,
29
+ IconRoute,
28
30
  IconScreenShare,
29
31
  IconSelector,
30
32
  IconSettings,
@@ -101,6 +103,7 @@ const APP_ICON_MAP: Record<string, typeof IconApps> = {
101
103
  Mail: IconMail,
102
104
  CalendarDays: IconCalendar,
103
105
  FileText: IconFileText,
106
+ LayoutBoard: IconLayoutBoard,
104
107
  BarChart2: IconChartBar,
105
108
  GalleryHorizontal: IconPresentation,
106
109
  Video: IconVideo,
@@ -110,6 +113,7 @@ const APP_ICON_MAP: Record<string, typeof IconApps> = {
110
113
  Code: IconCode,
111
114
  Contract: IconContract,
112
115
  MessageCircle: IconMessageCircle,
116
+ Route: IconRoute,
113
117
  ScreenShare: IconScreenShare,
114
118
  Brush: IconBrush,
115
119
  Brain: IconBrain,
@@ -8,8 +8,15 @@ import {
8
8
  } from "./embed-auth.js";
9
9
  import { bumpChangeVersion } from "./use-change-version.js";
10
10
 
11
+ interface Query {
12
+ queryKey: readonly unknown[];
13
+ }
14
+
11
15
  interface QueryClient {
12
- invalidateQueries(opts?: { queryKey?: string[] }): void;
16
+ invalidateQueries(opts?: {
17
+ queryKey?: string[];
18
+ predicate?: (query: Query) => boolean;
19
+ }): void;
13
20
  }
14
21
 
15
22
  const POLL_ABORT_MIN_MS = 10_000;
@@ -475,6 +482,10 @@ export function _resetSyncTransportRegistryForTests(): void {
475
482
  * @param options.ignoreSource - Skip events whose `requestSource` matches this
476
483
  * value. Use a per-tab ID so the UI ignores its own writes while still
477
484
  * picking up changes from other tabs, agents, and scripts.
485
+ * @param options.actionInvalidatePredicate - Optional filter for the broad
486
+ * compatibility invalidate triggered by `action` events. Use this to keep
487
+ * expensive active queries on explicit-refresh semantics while still letting
488
+ * normal source-versioned queries react through `useChangeVersion`.
478
489
  */
479
490
  export function useDbSync(
480
491
  options: {
@@ -489,6 +500,7 @@ export function useDbSync(
489
500
  fallbackInterval?: number;
490
501
  pauseWhenHidden?: boolean;
491
502
  ignoreSource?: string;
503
+ actionInvalidatePredicate?: (query: Query) => boolean;
492
504
  } = {},
493
505
  ): void {
494
506
  const {
@@ -508,6 +520,10 @@ export function useDbSync(
508
520
 
509
521
  const ignoreSourceRef = useRef(options.ignoreSource);
510
522
  ignoreSourceRef.current = options.ignoreSource;
523
+ const actionInvalidatePredicateRef = useRef(
524
+ options.actionInvalidatePredicate,
525
+ );
526
+ actionInvalidatePredicateRef.current = options.actionInvalidatePredicate;
511
527
 
512
528
  useEffect(() => {
513
529
  const id = Symbol("useDbSync");
@@ -550,7 +566,8 @@ export function useDbSync(
550
566
  // signal, so refresh active queries broadly as a compatibility
551
567
  // safety net. Other event sources stay targeted to avoid request
552
568
  // storms from noisy domain-specific writes.
553
- queryClient.invalidateQueries();
569
+ const predicate = actionInvalidatePredicateRef.current;
570
+ queryClient.invalidateQueries(predicate ? { predicate } : undefined);
554
571
  }
555
572
 
556
573
  // Framework-level invalidate: a small, fixed list of query-key
@@ -4,8 +4,15 @@ export interface AuthMarketingContent {
4
4
  description?: string;
5
5
  features?: string[];
6
6
  runLocalCommand?: string;
7
+ signupLocalModeNote?: {
8
+ text: string;
9
+ command: string;
10
+ };
7
11
  }
8
12
 
13
+ const PLAN_LOCAL_FILES_COMMAND =
14
+ "npx @agent-native/core@latest skills add visual-plan --mode local-files --scope user";
15
+
9
16
  export interface ResolveBuiltInAuthMarketingOptions {
10
17
  requestHost?: string;
11
18
  requestPath?: string;
@@ -71,6 +78,11 @@ export const BUILT_IN_AUTH_MARKETING: Record<string, AuthMarketingContent> = {
71
78
  "Review PR recaps with diagrams, file maps, and annotated code",
72
79
  "Share links for async comments and product review",
73
80
  ],
81
+ runLocalCommand: PLAN_LOCAL_FILES_COMMAND,
82
+ signupLocalModeNote: {
83
+ text: "Prefer no account or self-hosting? Switch /visual-plan to local files only:",
84
+ command: PLAN_LOCAL_FILES_COMMAND,
85
+ },
74
86
  },
75
87
  design: {
76
88
  appName: "Agent-Native Design",
@@ -169,6 +181,9 @@ function cloneMarketing(marketing: AuthMarketingContent): AuthMarketingContent {
169
181
  return {
170
182
  ...marketing,
171
183
  features: marketing.features ? [...marketing.features] : undefined,
184
+ signupLocalModeNote: marketing.signupLocalModeNote
185
+ ? { ...marketing.signupLocalModeNote }
186
+ : undefined,
172
187
  };
173
188
  }
174
189
 
@@ -1254,6 +1254,14 @@ export function getOnboardingHtml(opts: OnboardingHtmlOptions = {}): string {
1254
1254
  }
1255
1255
  : undefined;
1256
1256
  const runLocalCommand = marketing?.runLocalCommand?.trim();
1257
+ const signupLocalModeNote =
1258
+ isAgentNativeHostedHost(opts.requestHost) &&
1259
+ marketing?.signupLocalModeNote?.command.trim()
1260
+ ? {
1261
+ text: marketing.signupLocalModeNote.text.trim(),
1262
+ command: marketing.signupLocalModeNote.command.trim(),
1263
+ }
1264
+ : undefined;
1257
1265
  const brandMarkSrc = withAppBasePath("/agent-native-icon-dark.svg");
1258
1266
  const socialImageUrl = withAgentNativeSocialImageCacheBuster(
1259
1267
  opts.requestOrigin
@@ -1336,6 +1344,13 @@ ${localeMenuItemsHtml}
1336
1344
  const signupLegalNoteHtml = signupLegalNotice
1337
1345
  ? ` <p class="legal-note">${localizedValue(signupLegalNotice.prefix, "legalPrefix")} <a href="${esc(signupLegalNotice.termsUrl)}" target="_blank" rel="noreferrer"${localizedAnchorLabel(signupLegalNotice.termsLabel, "legalTerms")}</a> ${localizedValue(signupLegalNotice.connector, "legalConnector")} <a href="${esc(signupLegalNotice.privacyUrl)}" target="_blank" rel="noreferrer"${localizedAnchorLabel(signupLegalNotice.privacyLabel, "legalPrivacy")}</a>${localizedValue(signupLegalNotice.suffix, "legalSuffix")}</p>`
1338
1346
  : "";
1347
+ const signupLocalModeNoteHtml = signupLocalModeNote
1348
+ ? ` <div class="signup-local-mode-note" id="signup-local-mode-note" data-command="${esc(signupLocalModeNote.command)}">
1349
+ <p>${esc(signupLocalModeNote.text)}</p>
1350
+ <code>${esc(signupLocalModeNote.command)}</code>
1351
+ <button type="button" class="copy-run-local" id="copy-signup-local-mode" onclick="__anCopySignupLocalModeCommand()"${i18nAttr("copyCommand")}>${esc(t("copyCommand"))}</button>
1352
+ </div>`
1353
+ : "";
1339
1354
  const googleSignInNotice = opts.googleSignInNotice;
1340
1355
  const googleNoticeBodyParts = googleSignInNotice
1341
1356
  ? (Array.isArray(googleSignInNotice.body)
@@ -2025,6 +2040,33 @@ ${
2025
2040
  text-underline-offset: 2px;
2026
2041
  }
2027
2042
  .legal-note a:hover { color: #aaa; }
2043
+ .signup-local-mode-note {
2044
+ margin-top: 0.75rem;
2045
+ padding: 0.625rem;
2046
+ color: #777;
2047
+ background: rgba(255,255,255,0.025);
2048
+ border: 1px solid rgba(255,255,255,0.08);
2049
+ border-radius: 8px;
2050
+ font-size: 0.6875rem;
2051
+ line-height: 1.45;
2052
+ text-align: left;
2053
+ }
2054
+ .signup-local-mode-note p {
2055
+ margin: 0 0 0.5rem;
2056
+ }
2057
+ .signup-local-mode-note code {
2058
+ display: block;
2059
+ overflow-x: auto;
2060
+ padding-bottom: 0.125rem;
2061
+ color: #b8b8b8;
2062
+ font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
2063
+ font-size: 0.6875rem;
2064
+ line-height: 1.5;
2065
+ white-space: nowrap;
2066
+ }
2067
+ .signup-local-mode-note .copy-run-local {
2068
+ margin-top: 0.5rem;
2069
+ }
2028
2070
  .msg { margin-top: 0.75rem; font-size: 0.8125rem; display: none; }
2029
2071
  .msg.error { color: #f87171; }
2030
2072
  .msg.success { color: #33C4FF; }
@@ -2376,6 +2418,7 @@ ${
2376
2418
  <input id="s-pass2" type="password" autocomplete="new-password" placeholder="${esc(t("confirmPasswordPlaceholder"))}"${i18nPlaceholderAttr("confirmPasswordPlaceholder")} required minlength="8" />
2377
2419
  <button type="submit"${i18nAttr("createAccount")}>${esc(t("createAccount"))}</button>
2378
2420
  ${signupLegalNoteHtml}
2421
+ ${signupLocalModeNoteHtml}
2379
2422
  <p class="msg" id="s-msg"></p>
2380
2423
  </form>
2381
2424
 
@@ -3705,7 +3748,7 @@ ${
3705
3748
  }
3706
3749
  ${starfieldScript}
3707
3750
  ${
3708
- runLocalCommand
3751
+ runLocalCommand || signupLocalModeNote
3709
3752
  ? `
3710
3753
  function __anSetRunLocalCommandOpen(open) {
3711
3754
  var panel = document.getElementById('run-local-panel');
@@ -3740,6 +3783,9 @@ ${
3740
3783
  function __anCopyRunLocalCommand() {
3741
3784
  __anCopyCommandFromPanel('run-local-panel', 'copy-run-local');
3742
3785
  }
3786
+ function __anCopySignupLocalModeCommand() {
3787
+ __anCopyCommandFromPanel('signup-local-mode-note', 'copy-signup-local-mode');
3788
+ }
3743
3789
  function __anCopyGoogleNoticeRunLocalCommand() {
3744
3790
  __anCopyCommandFromPanel('google-preflight-run-local-panel', 'copy-google-preflight-run-local');
3745
3791
  }`
@@ -25,6 +25,12 @@ Legacy settings keys such as `u:<email>:dashboard-*`, `u:<email>:sql-dashboard-*
25
25
 
26
26
  Use `update-dashboard` for dashboard edits. It resolves the current user/org context, validates the config, applies JSON-pointer operations when provided, writes the SQL-backed record, and preserves sharing semantics.
27
27
 
28
+ Never use `db-patch`, raw SQL, or settings-key edits to create or modify a
29
+ dashboard config. Those bypass the dashboard action's access checks, SQL
30
+ validation, collab sync, and proof-of-done return. If an `update-dashboard`
31
+ call fails because the argument shape was wrong, fix the `update-dashboard`
32
+ arguments and retry once — do not switch tools.
33
+
28
34
  ## Valid Panel Sources
29
35
 
30
36
  `panel.source` is a backend selector, not a table name. It must be one of:
@@ -174,6 +180,73 @@ pnpm action update-dashboard --dashboardId weekly-metrics --config '<full json>'
174
180
 
175
181
  After a mutation, navigate to the dashboard if the user is elsewhere. The app syncs through the framework's polling/query invalidation path.
176
182
 
183
+ ### Existing Dashboard Edits
184
+
185
+ When the user asks to change existing panels:
186
+
187
+ 1. Read the current dashboard config through the dashboard/action surface.
188
+ 2. Find panel indexes by `panel.id` from the current config you just read. Do
189
+ not rely on seed-file order, stale memory, or screenshots.
190
+ 3. Build one `ops` array that includes every change.
191
+ 4. Call `update-dashboard` once.
192
+ 5. Verify the returned `panelCount`, `appliedOps`, and `summary`. If possible,
193
+ read the affected panels back and confirm the exact fields changed.
194
+
195
+ For native production tools, pass `ops` as a native array:
196
+
197
+ ```json
198
+ {
199
+ "dashboardId": "weekly-metrics",
200
+ "ops": [
201
+ {
202
+ "op": "replace",
203
+ "path": "/panels/3/sql",
204
+ "value": "SELECT COUNT(*) AS value FROM analytics_events"
205
+ },
206
+ {
207
+ "op": "replace",
208
+ "path": "/panels/3/config/description",
209
+ "value": "Updated definition."
210
+ }
211
+ ]
212
+ }
213
+ ```
214
+
215
+ The quoted JSON examples in this skill are for shell commands only. In native
216
+ tool calls, do not pass `ops` as a string. If the tool complains about the
217
+ shape, retry `update-dashboard` with a native array and continue from the same
218
+ dashboard config.
219
+
220
+ For SQL-only panel edits, replacing `/panels/<index>/sql` is enough. If the
221
+ metric semantics changed, also replace `/panels/<index>/config/description` so
222
+ the visible dashboard explains the new definition. If the title, source, chart
223
+ type, width, or config shape changes together, replace the whole panel object at
224
+ `/panels/<index>` in the same `ops` array.
225
+
226
+ ### First-Party User Metrics
227
+
228
+ For first-party `/track` events, be precise about identity:
229
+
230
+ | Metric intent | Identity expression |
231
+ | -------------------------------------------- | -------------------------------------------------------- |
232
+ | Account users, DAU, WAU, retention, cohorts | `NULLIF(user_id, '')` plus `NULLIF(user_id, '') IS NOT NULL`, but only on events that actually represent the activity being measured |
233
+ | Signed-in visitor activity | `event_name = 'session status' AND signed_in = 'true'` keyed by `COALESCE(NULLIF(user_id, ''), NULLIF(anonymous_id, ''))`, labeled as signed-in visitors rather than account users |
234
+ | Public traffic, visitors, clip/share viewers | `COALESCE(NULLIF(user_id, ''), NULLIF(anonymous_id, ''))` |
235
+
236
+ Do not call anonymous visitors "users" in dashboard labels or descriptions.
237
+ When a user asks for DAU, WAU, retention, repeat users, or account cohorts,
238
+ exclude logged-out traffic unless they explicitly ask for visitor metrics. If
239
+ the active/session events do not include account identity, do not substitute
240
+ signup or identify events and call that DAU/WAU. Either update instrumentation to
241
+ send account identity on active events, or label the dashboard metric as
242
+ signed-in visitor activity.
243
+
244
+ For template/app activity metrics, exclude `docs` from DAU, WAU, retention, and
245
+ repeat-user panels. A docs event may carry `signed_in = true` from shared auth
246
+ state or tracker context, but docs traffic is not app usage and should not appear
247
+ as an app/template series. Use a minimum cohort-size threshold for retention
248
+ rates so one or two identities cannot create misleading 100% or 0% spikes.
249
+
177
250
  ## Building Large First-Party Dashboards (compose-dashboard)
178
251
 
179
252
  For a **first-party analytics** dashboard, prefer `compose-dashboard` over hand-authoring a big `update-dashboard` config. You name the metrics; the SERVER expands each into a full, validated panel (SQL + chart config) from the shipped metric catalog and saves them in ONE atomic call. This avoids the failure mode where the agent must stream a giant multi-panel `update-dashboard` argument inside the ~40s budget — that big tool-call can't be resumed mid-stream and is all-or-nothing on validation, so the agent thrashes (repeated update-dashboard + tool-search, never landing).
@@ -202,6 +275,7 @@ Hosted agent runs have a **~40s budget**. Many sequential `update-dashboard` cal
202
275
  - **Batch ALL changes into ONE `update-dashboard` call.** A single `update-dashboard` is atomic: it applies every op to an in-memory config, validates all panel SQL, then upserts once. Never loop the action.
203
276
  - To add N panels, pass N ops in one call: `ops: [{op:"insert", path:"/panels/-", value:<panel>}, … ]` (`/panels/-` appends to the end).
204
277
  - The `ops` format needs no discovery: each op is `{ op, path, from?, value? }`, `op ∈ set | replace | remove | insert | move | move-before`, and `path` is a JSON Pointer (e.g. `/panels/3`, `/panels/3/title`, `/name`).
278
+ - In native tool calls, `ops` is an array, not a JSON string. Shell commands quote JSON only because shells need strings.
205
279
  - **To add a shipped template's panels, prefer `install-dashboard-template` with `mergePanels: true`** and the existing `dashboardId`. It appends only the template panels whose id is not already present (preserving existing panels and order) in one atomic save — you don't author each panel yourself.
206
280
  - **Always verify the returned proof-of-done and report it.** `update-dashboard` returns `panelCount`, `appliedOps`, and a `summary` string; `install-dashboard-template --mergePanels` returns `addedPanelIds`, `skippedExistingIds`, and `panelCount`. Tell the user the resulting panel count instead of assuming success.
207
281
 
@@ -243,6 +317,8 @@ Writes require editor access; deletes require admin access. Owners always satisf
243
317
 
244
318
  - Never fabricate data or create a dashboard from guessed schema. A panel's SQL must hit a real source; do not present figures you did not actually query.
245
319
  - Never write dashboard configs into the settings table.
320
+ - Never use `db-patch` as a fallback for dashboard config edits. Use
321
+ `update-dashboard` and fix the action arguments.
246
322
  - Never set `panel.source` to a table name or unsupported backend.
247
323
  - Use `first-party` for `/track` data and `query-agent-native-analytics` for ad-hoc first-party event questions.
248
324
  - Use `update-dashboard` for creates and edits.
@@ -416,6 +416,7 @@ export default defineAction({
416
416
  "it resolves org vs. user scope correctly so the edit lands on the row the UI actually renders. " +
417
417
  "BATCH ALL EDITS INTO A SINGLE CALL. Never call this action repeatedly in a loop: hosted agent runs have a ~40s budget, and many sequential update-dashboard calls time out mid-way and leave the dashboard in a partial state even though earlier calls looked like they succeeded. Put every change you want to make into one `ops` array (or one `config`). " +
418
418
  "`ops` is an array of { op, path, from?, value? } applied in order in a single atomic save. " +
419
+ "When calling this as a native tool, pass `ops` as a real array, not a JSON string; the shell examples quote JSON only for CLI parsing. If a call fails because `ops` was stringified, retry this action with a native array instead of switching to db-patch or settings writes. " +
419
420
  "`op` is one of: set | replace | remove | insert | move | move-before. " +
420
421
  "`path` is a JSON Pointer into the config (e.g. `/panels/3` is the 4th panel, `/panels/3/title` is its title, `/name` is the dashboard name). The special index `-` means the end of an array: `/panels/-` appends. " +
421
422
  "`value` is the panel or object to set/insert. `from` is the source JSON Pointer for move / move-before. " +
@@ -850,7 +850,11 @@ export function SqlChart({
850
850
  const isSection = panel.chartType === "section";
851
851
  const shouldQuery = !isSection && loadData;
852
852
  const sql = serializePanelSql(resolvedSql ?? panel.sql);
853
- const { data: result, isLoading } = useSqlQuery(
853
+ const {
854
+ data: result,
855
+ isLoading,
856
+ error: queryError,
857
+ } = useSqlQuery(
854
858
  ["sql-chart", panel.id, sql, panel.source],
855
859
  sql,
856
860
  panel.source,
@@ -859,7 +863,14 @@ export function SqlChart({
859
863
  );
860
864
 
861
865
  const rawRows = result?.rows ?? [];
862
- const error = result?.error;
866
+ const queryErrorMessage =
867
+ queryError instanceof Error
868
+ ? queryError.message
869
+ : queryError
870
+ ? String(queryError)
871
+ : undefined;
872
+ const error =
873
+ rawRows.length === 0 ? (result?.error ?? queryErrorMessage) : undefined;
863
874
 
864
875
  const { rows, forcedYKeys } = useMemo(() => {
865
876
  if (panel.config?.pivot && rawRows.length) {
@@ -347,6 +347,7 @@ const enUS = {
347
347
  sectionOptions: "Section options",
348
348
  panelOptions: "Panel options",
349
349
  fullScreen: "Full screen",
350
+ refresh: "Refresh",
350
351
  downloadCsv: "Download CSV",
351
352
  viewSql: "View SQL",
352
353
  dragToReorder: "Drag to reorder",
@@ -4969,6 +4970,7 @@ const translatedAnalyticsDebtTranslations = {
4969
4970
  failedToSave: "保存失败",
4970
4971
  formatSql: "格式 SQL",
4971
4972
  fullScreen: "全屏",
4973
+ refresh: "刷新",
4972
4974
  hiddenDescription:
4973
4975
  "该仪表板在常规列表中是隐藏的。它仍然可以通过直接链接搜索和打开。",
4974
4976
  noActiveFilters: "当前没有活动的过滤器 - 此视图将应用默认过滤器状态。",
@@ -5142,6 +5144,7 @@ const translatedAnalyticsDebtTranslations = {
5142
5144
  failedToSave: "No se pudo guardar",
5143
5145
  formatSql: "Formato SQL",
5144
5146
  fullScreen: "Pantalla completa",
5147
+ refresh: "Actualizar",
5145
5148
  hiddenDescription:
5146
5149
  "Este panel está oculto de las listas habituales. Se puede buscar y abrir mediante enlace directo.",
5147
5150
  noActiveFilters:
@@ -5318,6 +5321,7 @@ const translatedAnalyticsDebtTranslations = {
5318
5321
  failedToSave: "Échec de l'enregistrement",
5319
5322
  formatSql: "Format SQL",
5320
5323
  fullScreen: "Plein écran",
5324
+ refresh: "Actualiser",
5321
5325
  hiddenDescription:
5322
5326
  "Ce tableau de bord est masqué dans les listes régulières. Il reste consultable et ouvrable par lien direct.",
5323
5327
  noActiveFilters:
@@ -5495,6 +5499,7 @@ const translatedAnalyticsDebtTranslations = {
5495
5499
  failedToSave: "Speichern fehlgeschlagen",
5496
5500
  formatSql: "Formatieren Sie SQL",
5497
5501
  fullScreen: "Vollbild",
5502
+ refresh: "Aktualisieren",
5498
5503
  hiddenDescription:
5499
5504
  "Dieses Dashboard ist vor regulären Listen verborgen. Es bleibt durchsuchbar und kann über einen direkten Link geöffnet werden.",
5500
5505
  noActiveFilters:
@@ -5667,6 +5672,7 @@ const translatedAnalyticsDebtTranslations = {
5667
5672
  failedToSave: "保存に失敗しました",
5668
5673
  formatSql: "形式 SQL",
5669
5674
  fullScreen: "全画面表示",
5675
+ refresh: "更新",
5670
5676
  hiddenDescription:
5671
5677
  "このダッシュボードは通常のリストには表示されません。引き続き検索可能で、直接リンクで開くことができます。",
5672
5678
  noActiveFilters:
@@ -5838,6 +5844,7 @@ const translatedAnalyticsDebtTranslations = {
5838
5844
  failedToSave: "저장하지 못했습니다.",
5839
5845
  formatSql: "SQL 형식",
5840
5846
  fullScreen: "전체 화면",
5847
+ refresh: "새로고침",
5841
5848
  hiddenDescription:
5842
5849
  "이 대시보드는 일반 목록에서 숨겨져 있습니다. 직접 링크를 통해 검색 및 열 수 있습니다.",
5843
5850
  noActiveFilters:
@@ -6013,6 +6020,7 @@ const translatedAnalyticsDebtTranslations = {
6013
6020
  failedToSave: "Falha ao salvar",
6014
6021
  formatSql: "Formato SQL",
6015
6022
  fullScreen: "Tela cheia",
6023
+ refresh: "Atualizar",
6016
6024
  hiddenDescription:
6017
6025
  "Este painel está oculto nas listas regulares. Ele permanece pesquisável e aberto por link direto.",
6018
6026
  noActiveFilters:
@@ -6184,6 +6192,7 @@ const translatedAnalyticsDebtTranslations = {
6184
6192
  failedToSave: "सहेजने में विफल",
6185
6193
  formatSql: "प्रारूप SQL",
6186
6194
  fullScreen: "पूर्ण स्क्रीन",
6195
+ refresh: "रीफ़्रेश करें",
6187
6196
  hiddenDescription:
6188
6197
  "यह डैशबोर्ड नियमित सूचियों से छिपा हुआ है. यह सीधे लिंक द्वारा खोजने योग्य और खोलने योग्य रहता है।",
6189
6198
  noActiveFilters:
@@ -6355,6 +6364,7 @@ const translatedAnalyticsDebtTranslations = {
6355
6364
  failedToSave: "فشل الحفظ",
6356
6365
  formatSql: "تنسيق SQL",
6357
6366
  fullScreen: "شاشة كاملة",
6367
+ refresh: "تحديث",
6358
6368
  hiddenDescription:
6359
6369
  "لوحة التحكم هذه مخفية من القوائم العادية. يظل قابلاً للبحث والفتح عن طريق الرابط المباشر.",
6360
6370
  noActiveFilters:
@@ -12,40 +12,114 @@ export interface SqlQueryResult {
12
12
  schema?: { name: string; type: string }[];
13
13
  }
14
14
 
15
+ const MAX_CONCURRENT_SQL_QUERIES = 4;
16
+
17
+ type PendingSqlQuerySlot = {
18
+ resolve: (release: () => void) => void;
19
+ reject: (reason: unknown) => void;
20
+ signal?: AbortSignal;
21
+ onAbort: () => void;
22
+ };
23
+
24
+ let activeSqlQueries = 0;
25
+ const pendingSqlQuerySlots: PendingSqlQuerySlot[] = [];
26
+
27
+ function createAbortError(): Error {
28
+ if (typeof DOMException !== "undefined") {
29
+ return new DOMException("SQL query aborted", "AbortError");
30
+ }
31
+ const error = new Error("SQL query aborted");
32
+ error.name = "AbortError";
33
+ return error;
34
+ }
35
+
36
+ function createSqlQueryRelease(): () => void {
37
+ let released = false;
38
+ return () => {
39
+ if (released) return;
40
+ released = true;
41
+ activeSqlQueries = Math.max(0, activeSqlQueries - 1);
42
+ drainSqlQuerySlots();
43
+ };
44
+ }
45
+
46
+ function drainSqlQuerySlots(): void {
47
+ while (
48
+ activeSqlQueries < MAX_CONCURRENT_SQL_QUERIES &&
49
+ pendingSqlQuerySlots.length > 0
50
+ ) {
51
+ const pending = pendingSqlQuerySlots.shift();
52
+ if (!pending) return;
53
+ pending.signal?.removeEventListener("abort", pending.onAbort);
54
+ if (pending.signal?.aborted) {
55
+ pending.reject(createAbortError());
56
+ continue;
57
+ }
58
+ activeSqlQueries += 1;
59
+ pending.resolve(createSqlQueryRelease());
60
+ }
61
+ }
62
+
63
+ async function acquireSqlQuerySlot(signal?: AbortSignal): Promise<() => void> {
64
+ if (signal?.aborted) throw createAbortError();
65
+ return new Promise((resolve, reject) => {
66
+ const pending: PendingSqlQuerySlot = {
67
+ resolve,
68
+ reject,
69
+ signal,
70
+ onAbort: () => {
71
+ const index = pendingSqlQuerySlots.indexOf(pending);
72
+ if (index >= 0) pendingSqlQuerySlots.splice(index, 1);
73
+ reject(createAbortError());
74
+ },
75
+ };
76
+ signal?.addEventListener("abort", pending.onAbort, { once: true });
77
+ pendingSqlQuerySlots.push(pending);
78
+ drainSqlQuerySlots();
79
+ });
80
+ }
81
+
82
+ async function readSqlQueryError(res: Response): Promise<string> {
83
+ const body = await res.json().catch(() => ({}));
84
+ return typeof body?.error === "string"
85
+ ? body.error
86
+ : `Query failed (${res.status})`;
87
+ }
88
+
15
89
  export async function executeSqlQuery(
16
90
  sql: string,
17
91
  source: DataSourceType,
18
92
  signal?: AbortSignal,
19
93
  ): Promise<SqlQueryResult> {
20
94
  const token = await getIdToken();
21
- const res = await fetch(appApiPath("/api/sql-query"), {
22
- method: "POST",
23
- signal,
24
- headers: {
25
- "Content-Type": "application/json",
26
- ...(token && { Authorization: `Bearer ${token}` }),
27
- },
28
- body: JSON.stringify({ query: sql, source }),
29
- });
95
+ const release = await acquireSqlQuerySlot(signal);
96
+ let res: Response;
97
+ try {
98
+ res = await fetch(appApiPath("/api/sql-query"), {
99
+ method: "POST",
100
+ signal,
101
+ headers: {
102
+ "Content-Type": "application/json",
103
+ ...(token && { Authorization: `Bearer ${token}` }),
104
+ },
105
+ body: JSON.stringify({ query: sql, source }),
106
+ });
107
+ } finally {
108
+ release();
109
+ }
30
110
 
31
111
  if (!res.ok) {
32
- const body = await res.json().catch(() => ({}));
33
- return {
34
- rows: [],
35
- error: body.error || `Query failed (${res.status})`,
36
- };
112
+ throw new Error(await readSqlQueryError(res));
37
113
  }
38
114
 
39
115
  const data = await res.json();
40
116
 
41
117
  if (typeof data?.error === "string") {
42
- return {
43
- rows: [],
44
- error:
45
- typeof data.message === "string" && data.message
46
- ? data.message
47
- : data.error,
48
- };
118
+ throw new Error(
119
+ typeof data.message === "string" && data.message
120
+ ? data.message
121
+ : data.error,
122
+ );
49
123
  }
50
124
 
51
125
  if (data.bytesProcessed) {
@@ -68,6 +142,7 @@ export function useSqlQuery(
68
142
  refetchOnMount?: boolean | "always";
69
143
  refetchOnReconnect?: boolean | "always";
70
144
  refetchOnWindowFocus?: boolean | "always";
145
+ retry?: boolean | number;
71
146
  staleTime?: number;
72
147
  },
73
148
  ) {
@@ -79,6 +154,7 @@ export function useSqlQuery(
79
154
  refetchOnMount: options?.refetchOnMount ?? false,
80
155
  refetchOnReconnect: options?.refetchOnReconnect ?? false,
81
156
  refetchOnWindowFocus: options?.refetchOnWindowFocus ?? false,
157
+ retry: options?.retry ?? false,
82
158
  staleTime: options?.staleTime ?? 5 * 60 * 1000,
83
159
  });
84
160
  }