@agent-native/core-corpus 0.210.2 → 0.211.0

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 (192) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/templates/analytics/.agents/skills/actions/references/action-fields.md +5 -3
  3. package/corpus/templates/analytics/.agents/skills/dashboard-management/SKILL.md +41 -2
  4. package/corpus/templates/analytics/.agents/skills/data-querying/SKILL.md +4 -2
  5. package/corpus/templates/analytics/CHANGELOG.md +18 -0
  6. package/corpus/templates/analytics/actions/build-data-index.ts +11 -2
  7. package/corpus/templates/analytics/actions/configure-content-collection.ts +29 -4
  8. package/corpus/templates/analytics/actions/configure-first-party-events-model.ts +9 -7
  9. package/corpus/templates/analytics/actions/create-analytics-public-key.ts +4 -0
  10. package/corpus/templates/analytics/actions/estimate-content-signups-cost.ts +72 -0
  11. package/corpus/templates/analytics/actions/estimate-query-cost.ts +16 -134
  12. package/corpus/templates/analytics/actions/list-sigma-workbooks.ts +123 -42
  13. package/corpus/templates/analytics/actions/migrate-sigma-workbook.ts +20 -17
  14. package/corpus/templates/analytics/actions/revoke-analytics-public-key.ts +4 -0
  15. package/corpus/templates/analytics/app/components/dashboard/SigmaMigrationDialog.tsx +246 -680
  16. package/corpus/templates/analytics/app/components/layout/Sidebar.tsx +1 -1
  17. package/corpus/templates/analytics/app/i18n/en-US.ts +54 -55
  18. package/corpus/templates/analytics/app/pages/DashboardOverview.tsx +0 -1
  19. package/corpus/templates/analytics/app/pages/DataSources.tsx +56 -0
  20. package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/filter-vars.ts +4 -3
  21. package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/index.tsx +26 -8
  22. package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/interpolate.ts +3 -1
  23. package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/panel-cost-notice.ts +172 -0
  24. package/corpus/templates/analytics/app/pages/data-sources/SearchConsoleDetail.tsx +441 -0
  25. package/corpus/templates/analytics/changelog/2026-10-15-a-content-dashboard-lists-every-blog-article-with-its-author.md +6 -0
  26. package/corpus/templates/analytics/changelog/2026-10-15-data-sources-has-a-google-search-console-card-that-shows-the.md +6 -0
  27. package/corpus/templates/analytics/changelog/2026-10-16-the-content-dashboard-can-show-signups-per-blog-article.md +6 -0
  28. package/corpus/templates/analytics/changelog/2026-10-19-creating-or-revoking-a-public-analytics-key-now-asks-you-to-.md +6 -0
  29. package/corpus/templates/analytics/changelog/2026-10-19-search-sigma-workbooks-by-name-and-open-ones-a-teammate-alre.md +6 -0
  30. package/corpus/templates/analytics/changelog/2026-10-20-blog-signup-attribution-excludes-signups-before-the-first-vi.md +6 -0
  31. package/corpus/templates/analytics/changelog/2026-10-20-collapsed-analytics-navigation-starts-at-the-top.md +6 -0
  32. package/corpus/templates/analytics/changelog/2026-10-20-content-keeps-successful-coverage-dates-and-missing-day-gaps.md +6 -0
  33. package/corpus/templates/analytics/changelog/2026-10-20-private-github-repositories-can-be-indexed-with-your-connect.md +6 -0
  34. package/corpus/templates/analytics/changelog/2026-10-20-search-console-preserves-backfill-and-paused-collection.md +6 -0
  35. package/corpus/templates/analytics/changelog/2026-10-20-search-console-refreshes-historical-coverage-when-properties.md +6 -0
  36. package/corpus/templates/analytics/changelog/2026-10-20-search-console-setup-keeps-saved-properties-readable-and-sup.md +6 -0
  37. package/corpus/templates/analytics/seeds/dashboards/content-view.json +182 -0
  38. package/corpus/templates/analytics/server/db/schema.ts +1 -0
  39. package/corpus/templates/analytics/server/lib/bi-migration/external-dashboards-store.ts +56 -18
  40. package/corpus/templates/analytics/server/lib/bi-migration/unfinished-import.ts +64 -0
  41. package/corpus/templates/analytics/server/lib/builtin-dashboard-pass.ts +17 -2
  42. package/corpus/templates/analytics/server/lib/content-metrics/collect.ts +2 -0
  43. package/corpus/templates/analytics/server/lib/content-metrics/config.ts +92 -3
  44. package/corpus/templates/analytics/server/lib/content-metrics/legacy-content-view.json +172 -0
  45. package/corpus/templates/analytics/server/lib/content-metrics/migration.ts +2 -0
  46. package/corpus/templates/analytics/server/lib/content-metrics/steps/daily-metrics.ts +8 -3
  47. package/corpus/templates/analytics/server/lib/content-metrics/steps/search-console.ts +16 -3
  48. package/corpus/templates/analytics/server/lib/content-metrics/steps/signups.ts +240 -0
  49. package/corpus/templates/analytics/server/lib/content-metrics/store.ts +5 -0
  50. package/corpus/templates/analytics/server/lib/dashboard-catalog.ts +21 -0
  51. package/corpus/templates/analytics/server/lib/dashboard-panel-verification.ts +300 -5
  52. package/corpus/templates/analytics/server/lib/dashboard-seeds.ts +2 -0
  53. package/corpus/templates/analytics/server/lib/dashboards-store.ts +28 -0
  54. package/corpus/templates/analytics/server/lib/first-party-analytics-backend.ts +128 -24
  55. package/corpus/templates/analytics/server/lib/google-analytics.ts +56 -16
  56. package/corpus/templates/analytics/server/lib/index-build-github.ts +5 -0
  57. package/corpus/templates/analytics/server/lib/provider-credentials.ts +47 -2
  58. package/corpus/templates/analytics/server/lib/query-cost-estimate.ts +184 -5
  59. package/corpus/templates/analytics/server/plugins/db.ts +9 -1
  60. package/corpus/templates/analytics/shared/builtin-dashboard-ids.ts +2 -0
  61. package/corpus/templates/analytics/shared/search-console-property.ts +78 -0
  62. package/corpus/templates/assets/.agents/skills/actions/references/action-fields.md +5 -3
  63. package/corpus/templates/brain/.agents/skills/actions/references/action-fields.md +5 -3
  64. package/corpus/templates/calendar/.agents/skills/actions/references/action-fields.md +5 -3
  65. package/corpus/templates/calendar/actions/delete-event.ts +1 -3
  66. package/corpus/templates/calendar/actions/delete-events.ts +1 -2
  67. package/corpus/templates/calendar/actions/event-action-helpers.ts +0 -4
  68. package/corpus/templates/calendar/actions/update-event.ts +2 -6
  69. package/corpus/templates/chat/.agents/skills/actions/references/action-fields.md +5 -3
  70. package/corpus/templates/clips/.agents/skills/actions/references/action-fields.md +5 -3
  71. package/corpus/templates/clips/CHANGELOG.md +11 -24
  72. package/corpus/templates/clips/actions/create-recording-upload.ts +5 -1
  73. package/corpus/templates/clips/app/components/player/chapter-list.tsx +7 -6
  74. package/corpus/templates/clips/app/components/player/comment-composer.tsx +5 -1
  75. package/corpus/templates/clips/app/components/player/player-controls.tsx +1 -0
  76. package/corpus/templates/clips/app/components/player/scrubber.tsx +4 -2
  77. package/corpus/templates/clips/app/i18n/en-US.ts +3 -3
  78. package/corpus/templates/clips/app/lib/extension-auth.ts +70 -0
  79. package/corpus/templates/clips/app/root.tsx +22 -10
  80. package/corpus/templates/clips/app/routes/_app.notifications.tsx +3 -0
  81. package/corpus/templates/clips/app/routes/import.tsx +115 -110
  82. package/corpus/templates/clips/changelog/2026-10-16-agent-uploads-can-use-free-clips-storage.md +6 -0
  83. package/corpus/templates/clips/changelog/2026-10-19-agents-see-a-clip-s-frames-as-soon-as-it-finishes-processing.md +6 -0
  84. package/corpus/templates/clips/changelog/2026-10-19-chapter-list-times-now-match-the-player-s-clock-and-each-cha.md +6 -0
  85. package/corpus/templates/clips/changelog/2026-10-19-editing-a-comment-puts-the-cursor-after-its-text-and-reply-o.md +6 -0
  86. package/corpus/templates/clips/changelog/2026-10-19-youtube-import-followups.md +6 -0
  87. package/corpus/templates/clips/changelog/2026-10-20-locally-saved-desktop-recordings-now-appear-in-the-recovery-.md +6 -0
  88. package/corpus/templates/clips/desktop/src/app.tsx +29 -186
  89. package/corpus/templates/clips/desktop/src/components/RecordingRecovery.tsx +171 -16
  90. package/corpus/templates/clips/desktop/src/lib/record-first.ts +9 -32
  91. package/corpus/templates/clips/desktop/src/lib/recording-recovery.ts +30 -1
  92. package/corpus/templates/clips/server/lib/agent-frame-request.ts +5 -2
  93. package/corpus/templates/clips/server/lib/recording-upload-ticket.ts +8 -0
  94. package/corpus/templates/clips/server/routes/api/uploads/[recordingId]/chunk.post.ts +2 -0
  95. package/corpus/templates/content/.agents/skills/actions/references/action-fields.md +5 -3
  96. package/corpus/templates/content/CHANGELOG.md +12 -0
  97. package/corpus/templates/content/actions/_content-database-personal-view.ts +5 -15
  98. package/corpus/templates/content/actions/_database-navigation.ts +3 -3
  99. package/corpus/templates/content/actions/_database-utils.ts +10 -22
  100. package/corpus/templates/content/actions/_database-view-setup.ts +16 -76
  101. package/corpus/templates/content/actions/create-comment-ai-suggestion.ts +93 -44
  102. package/corpus/templates/content/actions/export-document.ts +2 -14
  103. package/corpus/templates/content/actions/suggest-document-edit.ts +5 -7
  104. package/corpus/templates/content/actions/update-content-database-view.ts +2 -14
  105. package/corpus/templates/content/app/components/editor/CommentsSidebar.tsx +86 -30
  106. package/corpus/templates/content/app/components/editor/DocumentEditor.tsx +64 -28
  107. package/corpus/templates/content/app/components/editor/DocumentProperties.tsx +6 -46
  108. package/corpus/templates/content/app/components/editor/PageDraftRecovery.tsx +72 -7
  109. package/corpus/templates/content/app/components/editor/TableHoverControls.tsx +292 -103
  110. package/corpus/templates/content/app/components/editor/ThreadSuggestion.tsx +147 -40
  111. package/corpus/templates/content/app/components/editor/VisualEditor.tsx +108 -11
  112. package/corpus/templates/content/app/components/editor/database/ContentTable.tsx +2 -1
  113. package/corpus/templates/content/app/components/editor/database/DatabaseView.tsx +150 -166
  114. package/corpus/templates/content/app/components/editor/database/database-cell-comments.tsx +515 -0
  115. package/corpus/templates/content/app/components/editor/database/database-item-comments.tsx +5 -43
  116. package/corpus/templates/content/app/components/editor/database/filter-sort.ts +8 -783
  117. package/corpus/templates/content/app/components/editor/live-editor-session.ts +5 -1
  118. package/corpus/templates/content/app/components/editor/page-draft-journal.ts +61 -1
  119. package/corpus/templates/content/app/components/editor/property-comments.tsx +35 -4
  120. package/corpus/templates/content/app/components/editor/property-date-format.ts +45 -0
  121. package/corpus/templates/content/app/components/editor/thread-suggestions.ts +30 -0
  122. package/corpus/templates/content/app/components/editor/useDocumentReconcileRecovery.ts +23 -4
  123. package/corpus/templates/content/app/components/sidebar/DocumentSidebar.tsx +36 -10
  124. package/corpus/templates/content/app/global.css +23 -0
  125. package/corpus/templates/content/app/i18n-data.ts +1 -0
  126. package/corpus/templates/content/changelog/2026-10-16-opening-a-page-in-a-new-tab-while-another-tab-is-still-savin.md +6 -0
  127. package/corpus/templates/content/changelog/2026-10-16-typing-steadily-in-one-tab-no-longer-makes-another-open-tab-.md +6 -0
  128. package/corpus/templates/content/changelog/2026-10-19-database-views-can-filter-the-same-property-more-than-once-n.md +6 -0
  129. package/corpus/templates/content/changelog/2026-10-19-edits-made-from-a-block-s-menu-or-a-table-s-row-and-column-m.md +6 -0
  130. package/corpus/templates/content/changelog/2026-10-19-starting-a-comment-puts-the-cursor-in-the-comment-box-ready-.md +6 -0
  131. package/corpus/templates/content/changelog/2026-10-19-table-row-and-column-options-now-appear-for-the-row-or-colum.md +6 -0
  132. package/corpus/templates/content/docs/product/capabilities/content.comment.page-owned.md +5 -1
  133. package/corpus/templates/content/docs/product/capabilities/content.revision.suggestions.md +3 -1
  134. package/corpus/templates/content/e2e/helpers.ts +10 -0
  135. package/corpus/templates/content/shared/api.ts +18 -12
  136. package/corpus/templates/content/shared/database-query.ts +107 -13
  137. package/corpus/templates/content/shared/suggestion-diff.ts +35 -0
  138. package/corpus/templates/crm/.agents/skills/actions/references/action-fields.md +5 -3
  139. package/corpus/templates/design/.agents/skills/actions/references/action-fields.md +5 -3
  140. package/corpus/templates/design/CHANGELOG.md +2 -0
  141. package/corpus/templates/design/actions/delete-file.ts +1 -4
  142. package/corpus/templates/design/app/components/design/LayersPanel.tsx +10 -1
  143. package/corpus/templates/design/app/components/visual-editor/ReviewCanvasPins.tsx +24 -4
  144. package/corpus/templates/design/app/pages/design-editor/commands/mode-change.ts +14 -9
  145. package/corpus/templates/design/app/pages/design-editor/domains/use-editor-modes.ts +23 -2
  146. package/corpus/templates/design/app/pages/design-editor/tool-state.ts +29 -0
  147. package/corpus/templates/design/changelog/2026-10-19-reply-and-edit-on-a-comment-in-the-sidebar-or-on-a-canvas-pi.md +6 -0
  148. package/corpus/templates/design/changelog/2026-10-20-comments-return-to-the-mode-they-came-from-and-layers-search.md +6 -0
  149. package/corpus/templates/design/e2e/global-setup.ts +185 -85
  150. package/corpus/templates/dispatch/.agents/skills/actions/references/action-fields.md +5 -3
  151. package/corpus/templates/dispatch/CHANGELOG.md +4 -0
  152. package/corpus/templates/dispatch/actions/apply-workspace-connection-setup.ts +4 -0
  153. package/corpus/templates/dispatch/actions/delete-workspace-connection.ts +4 -0
  154. package/corpus/templates/dispatch/actions/set-workspace-connection-grant.ts +4 -0
  155. package/corpus/templates/dispatch/actions/upsert-workspace-connection.ts +4 -0
  156. package/corpus/templates/dispatch/changelog/2026-10-19-vault-secrets-grants-link-tokens-app-access-and-workspace-co.md +6 -0
  157. package/corpus/templates/factory/.agents/skills/actions/references/action-fields.md +5 -3
  158. package/corpus/templates/factory/.agents/skills/review-latest-feedback/SKILL.md +105 -33
  159. package/corpus/templates/factory/.agents/skills/review-latest-feedback/references/channel-inventory.md +128 -74
  160. package/corpus/templates/factory/.agents/skills/review-latest-feedback/references/slack-reaction-replies.md +39 -11
  161. package/corpus/templates/forms/.agents/skills/actions/references/action-fields.md +5 -3
  162. package/corpus/templates/mail/.agents/skills/actions/references/action-fields.md +5 -3
  163. package/corpus/templates/mail/.agents/skills/contacts-and-crm/SKILL.md +22 -17
  164. package/corpus/templates/mail/AGENTS.md +1 -1
  165. package/corpus/templates/mail/CHANGELOG.md +14 -0
  166. package/corpus/templates/mail/actions/provider-api-request.ts +3 -3
  167. package/corpus/templates/mail/app/components/email/IntegrationsSidebar.tsx +211 -54
  168. package/corpus/templates/mail/app/hooks/use-inbox-threads.ts +26 -2
  169. package/corpus/templates/mail/app/i18n/en-US.ts +5 -1
  170. package/corpus/templates/mail/app/lib/gong-connection.ts +27 -0
  171. package/corpus/templates/mail/app/lib/thread-cache.ts +110 -9
  172. package/corpus/templates/mail/app/root.tsx +10 -2
  173. package/corpus/templates/mail/changelog/2026-10-20-connected-gong-settings-now-open-the-shared-integration-mana.md +6 -0
  174. package/corpus/templates/mail/changelog/2026-10-20-gong-connection-details-now-match-the-call-data-available-in.md +6 -0
  175. package/corpus/templates/mail/changelog/2026-10-20-improve-gong-setup-with-a-click-to-connect-flow-and-permissi.md +6 -0
  176. package/corpus/templates/mail/changelog/2026-10-20-mail-lets-you-retry-when-it-cannot-check-your-gong-access.md +6 -0
  177. package/corpus/templates/mail/changelog/2026-10-20-mail-thread-cache-stays-isolated-when-mailbox-scope-changes.md +6 -0
  178. package/corpus/templates/mail/changelog/2026-10-20-the-inbox-and-recently-opened-threads-now-open-instantly-fro.md +6 -0
  179. package/corpus/templates/mail/server/handlers/emails.ts +9 -0
  180. package/corpus/templates/plan/.agents/skills/actions/references/action-fields.md +5 -3
  181. package/corpus/templates/plan/CHANGELOG.md +4 -0
  182. package/corpus/templates/plan/app/pages/PlansPage.tsx +173 -33
  183. package/corpus/templates/plan/changelog/2026-10-19-opening-a-comment-thread-puts-the-cursor-in-the-reply-box-an.md +6 -0
  184. package/corpus/templates/slides/.agents/skills/actions/references/action-fields.md +5 -3
  185. package/corpus/templates/slides/app/components/editor/EditorToolbar.tsx +6 -42
  186. package/corpus/templates/slides/app/components/editor/FloatingToolbar.tsx +3 -7
  187. package/corpus/templates/slides/app/components/editor/SlideEditor.tsx +5 -14
  188. package/corpus/templates/slides/app/i18n/en-US.ts +0 -2
  189. package/corpus/templates/slides/app/pages/DeckEditor.tsx +27 -33
  190. package/corpus/templates/slides/scripts/edit-fidelity/run.ts +1 -3
  191. package/corpus/templates/tasks/.agents/skills/actions/references/action-fields.md +5 -3
  192. package/package.json +1 -1
@@ -38,6 +38,18 @@ import {
38
38
  describeError,
39
39
  runResolvedPanel,
40
40
  } from "./dashboard-panel-runner";
41
+ import {
42
+ describeReportSchedules,
43
+ estimateResolvedPanels,
44
+ formatBytes,
45
+ formatUsd,
46
+ QUERY_COST_THRESHOLDS,
47
+ scheduledDashboardRuns,
48
+ totalCost,
49
+ usdForBytes,
50
+ type PanelCostEstimate,
51
+ type QueryCostFailure,
52
+ } from "./query-cost-estimate";
41
53
 
42
54
  export type PanelVerificationStatus =
43
55
  | "ok"
@@ -83,6 +95,8 @@ export interface PanelVerification {
83
95
  truncated?: boolean;
84
96
  error?: string;
85
97
  note?: PanelUnverifiedNote;
98
+ /** The dry run's bytes, kept when they stopped the panel from running (`too_expensive`). */
99
+ estimatedBytes?: number;
86
100
  /** A section or extension panel: nothing to run, so only its config was checked. */
87
101
  visualOnly?: true;
88
102
  hint?: string;
@@ -144,7 +158,7 @@ const NOTE_REASONS: Record<PanelUnverifiedNote, string> = {
144
158
  "its data source is not connected (the viewer sees a connect prompt)",
145
159
  "skipped:program": "data-program panels are not run during verification",
146
160
  too_expensive:
147
- "the dry run is larger than the automatic-verification byte limit",
161
+ "the dry run is larger than the automatic-verification byte limit, so it was not run",
148
162
  "not_executed:over_budget":
149
163
  "more panels were affected than the per-save verification cap runs",
150
164
  };
@@ -274,6 +288,46 @@ export function touchedPanelIds(
274
288
  };
275
289
  }
276
290
 
291
+ /**
292
+ * The touched panels whose query changed: new panels, panels that start or
293
+ * stop running a query (a section turned into a chart), and panels whose
294
+ * source or resolved SQL differs. Any other chart-type or config edit reads
295
+ * the same bytes. Compares the first copy of each id, the one the edit API
296
+ * addresses.
297
+ */
298
+ function queryChangedPanelIds(
299
+ rawBase: Record<string, unknown> | null,
300
+ rawNext: Record<string, unknown>,
301
+ ids: readonly string[],
302
+ ): string[] {
303
+ const base = rawBase && normalizeDashboardConfig(rawBase);
304
+ const next = normalizeDashboardConfig(rawNext);
305
+ const firstCopies = (config: Record<string, unknown> | null) => {
306
+ const panels = new Map<string, Record<string, unknown>>();
307
+ for (const panel of config ? panelsOf(config) : []) {
308
+ if (typeof panel.id === "string" && !panels.has(panel.id)) {
309
+ panels.set(panel.id, panel);
310
+ }
311
+ }
312
+ return panels;
313
+ };
314
+ const before = firstCopies(base);
315
+ const after = firstCopies(next);
316
+ const baseVars = base ? resolveVerificationVars(base) : {};
317
+ const nextVars = resolveVerificationVars(next);
318
+ return ids.filter((id) => {
319
+ const was = before.get(id);
320
+ const now = after.get(id);
321
+ if (!now) return false;
322
+ return (
323
+ !was ||
324
+ hasSqlToRun(was) !== hasSqlToRun(now) ||
325
+ was.source !== now.source ||
326
+ resolvedSqlOf(was, baseVars) !== resolvedSqlOf(now, nextVars)
327
+ );
328
+ });
329
+ }
330
+
277
331
  export interface VisualPanelChange {
278
332
  panelId: string;
279
333
  title: string;
@@ -610,7 +664,11 @@ async function verifyOne(
610
664
  dryRun?.totalBytesProcessed !== undefined &&
611
665
  dryRun.totalBytesProcessed > state.maxBytes
612
666
  ) {
613
- return finish({ status: "unverified", note: "too_expensive" });
667
+ return finish({
668
+ status: "unverified",
669
+ note: "too_expensive",
670
+ estimatedBytes: dryRun.totalBytesProcessed,
671
+ });
614
672
  }
615
673
  if (!execute) {
616
674
  return finish({ status: "unverified", note: "not_executed:over_budget" });
@@ -955,6 +1013,170 @@ export interface PanelWriteVerdict {
955
1013
  reason: string;
956
1014
  }[];
957
1015
  nextStep?: string;
1016
+ /** What the changed panels cost to refresh; absent when none could be priced. */
1017
+ cost?: PanelWriteCost;
1018
+ }
1019
+
1020
+ export interface PanelWriteCost {
1021
+ /** Each changed panel's estimate at the dashboard's default filters. */
1022
+ panels: PanelCostEstimate[];
1023
+ total: ReturnType<typeof totalCost> & {
1024
+ /** Some changed panel could not be priced, so the total counts only the rest. */
1025
+ partial: boolean;
1026
+ };
1027
+ runsPerMonth: number | null;
1028
+ runsPerMonthBasis: string;
1029
+ }
1030
+
1031
+ const MAX_COST_PANELS = 60;
1032
+ /** Once verification is done, how much longer a write waits for its price. */
1033
+ const COST_GRACE_MS = 3_000;
1034
+ const COST_LATE =
1035
+ "Stopped: pricing took longer than the save. Estimate this panel with estimate-query-cost.";
1036
+ /** Sources whose panels can bill BigQuery, whether or not they can be priced. */
1037
+ const BILLABLE_SOURCES = new Set(["bigquery", "first-party", "program"]);
1038
+
1039
+ type WriteCostPanel = ReturnType<typeof resolveDashboardPanelQueries>[number];
1040
+
1041
+ /** The first copy of each panel to price, the one the edit API addresses. */
1042
+ function writeCostPanels(
1043
+ config: Record<string, unknown>,
1044
+ panelIds: readonly string[],
1045
+ ): WriteCostPanel[] {
1046
+ if (panelIds.length === 0) return [];
1047
+ const wanted = new Set(panelIds);
1048
+ const seen = new Set<string>();
1049
+ return resolveDashboardPanelQueries(config).filter((panel) => {
1050
+ if (!wanted.has(panel.panelId) || seen.has(panel.panelId)) return false;
1051
+ seen.add(panel.panelId);
1052
+ return true;
1053
+ });
1054
+ }
1055
+
1056
+ function writeCostOf(
1057
+ estimates: PanelCostEstimate[],
1058
+ runsPerMonth: number | null,
1059
+ runsPerMonthBasis: string,
1060
+ ): PanelWriteCost {
1061
+ return {
1062
+ panels: estimates,
1063
+ total: {
1064
+ ...totalCost(estimates),
1065
+ partial: estimates.some(
1066
+ (panel) =>
1067
+ panel.status === "failed" || panel.status === "not_estimated",
1068
+ ),
1069
+ },
1070
+ runsPerMonth,
1071
+ runsPerMonthBasis,
1072
+ };
1073
+ }
1074
+
1075
+ /** Every panel that can bill, marked unpriced; null when none can bill. */
1076
+ function unpricedWriteCost(
1077
+ panels: readonly WriteCostPanel[],
1078
+ failure: QueryCostFailure,
1079
+ error: string,
1080
+ ): PanelWriteCost | null {
1081
+ const billable = panels.filter((panel) => BILLABLE_SOURCES.has(panel.source));
1082
+ if (billable.length === 0) return null;
1083
+ return writeCostOf(
1084
+ billable.map((panel) => ({
1085
+ panelId: panel.panelId,
1086
+ title: panel.title,
1087
+ source: panel.source,
1088
+ status: "failed",
1089
+ failure,
1090
+ error,
1091
+ })),
1092
+ null,
1093
+ "none",
1094
+ );
1095
+ }
1096
+
1097
+ /** `work`'s result, or undefined if `stop` fires first. */
1098
+ function beforeStop<T>(
1099
+ work: Promise<T>,
1100
+ stop: AbortSignal,
1101
+ ): Promise<T | undefined> {
1102
+ if (stop.aborted) return Promise.resolve(undefined);
1103
+ let onStop: (() => void) | undefined;
1104
+ const stopped = new Promise<undefined>((resolve) => {
1105
+ onStop = () => resolve(undefined);
1106
+ stop.addEventListener("abort", onStop, { once: true });
1107
+ });
1108
+ return Promise.race([work, stopped]).finally(() => {
1109
+ if (onStop) stop.removeEventListener("abort", onStop);
1110
+ });
1111
+ }
1112
+
1113
+ /**
1114
+ * Prices the panels a write changed with the same free dry runs as
1115
+ * estimate-query-cost, at the default filters a fresh tab shows. Once `late`
1116
+ * fires it settles at once: panels already priced keep their estimates and
1117
+ * the rest go unpriced.
1118
+ */
1119
+ async function estimateWriteCost(
1120
+ panels: readonly WriteCostPanel[],
1121
+ dashboardId: string | undefined,
1122
+ signal: AbortSignal,
1123
+ late: AbortSignal,
1124
+ ): Promise<PanelWriteCost | null> {
1125
+ const scope = getCredentialContext();
1126
+ if (!scope || !dashboardId || panels.length === 0) return null;
1127
+ try {
1128
+ const schedule = await beforeStop(
1129
+ scheduledDashboardRuns(dashboardId, scope),
1130
+ late,
1131
+ );
1132
+ if (!schedule) return unpricedWriteCost(panels, "timeout", COST_LATE);
1133
+ const { reportSchedules, runsPerMonth } = schedule;
1134
+ const estimates = await estimateResolvedPanels(
1135
+ panels.slice(0, MAX_COST_PANELS),
1136
+ scope,
1137
+ runsPerMonth,
1138
+ Date.now() + PANEL_VERIFICATION_BUDGET_MS,
1139
+ signal,
1140
+ { signal: late, error: COST_LATE },
1141
+ );
1142
+ const skipped: PanelCostEstimate[] = panels
1143
+ .slice(MAX_COST_PANELS)
1144
+ .filter((panel) => BILLABLE_SOURCES.has(panel.source))
1145
+ .map((panel) => ({
1146
+ panelId: panel.panelId,
1147
+ title: panel.title,
1148
+ source: panel.source,
1149
+ status: "not_estimated",
1150
+ reason: `One write prices at most ${MAX_COST_PANELS} panels; estimate-query-cost can price this one.`,
1151
+ }));
1152
+ return writeCostOf(
1153
+ [...estimates, ...skipped],
1154
+ runsPerMonth,
1155
+ reportSchedules > 0
1156
+ ? describeReportSchedules(reportSchedules, scope.orgId)
1157
+ : "none",
1158
+ );
1159
+ } catch (error) {
1160
+ // coercion-ok: a failed estimate leaves the save and its verdict as they were; the summary says the panels went unpriced.
1161
+ return unpricedWriteCost(panels, "no_estimate", describeError(error));
1162
+ }
1163
+ }
1164
+
1165
+ /**
1166
+ * Pricing is informational, so once verification is done an estimate still
1167
+ * running gets COST_GRACE_MS more. Then `late` fires, and the estimate
1168
+ * settles with what it has priced.
1169
+ */
1170
+ async function settleWriteCost(
1171
+ pending: Promise<PanelWriteCost | null>,
1172
+ late: AbortController,
1173
+ ): Promise<PanelWriteCost | null> {
1174
+ const timer = setTimeout(() => late.abort(), COST_GRACE_MS);
1175
+ try {
1176
+ return await pending;
1177
+ } finally {
1178
+ clearTimeout(timer);
1179
+ }
958
1180
  }
959
1181
 
960
1182
  /** One line saying what the viewer sees, or why that could not be checked. */
@@ -969,7 +1191,11 @@ export function describePanelOutcome(panel: PanelVerification): string {
969
1191
  }`;
970
1192
  }
971
1193
  if (panel.status === "unverified") {
972
- const why = panel.note ? NOTE_REASONS[panel.note] : "could not be checked";
1194
+ const why = `${panel.note ? NOTE_REASONS[panel.note] : "could not be checked"}${
1195
+ panel.estimatedBytes === undefined
1196
+ ? ""
1197
+ : ` (~${formatBytes(panel.estimatedBytes)}, ${formatUsd(usdForBytes(panel.estimatedBytes))} per refresh)`
1198
+ }`;
973
1199
  return panel.error ? `${why} (${clipHead(panel.error, 200)})` : why;
974
1200
  }
975
1201
  if (panel.status === "empty") {
@@ -1024,6 +1250,23 @@ export async function verifyPanelWrite(
1024
1250
  const visualIds = visual
1025
1251
  .filter((change) => change.change !== "removed")
1026
1252
  .map((change) => change.panelId);
1253
+ // The estimate runs beside verification and stops if the write is refused.
1254
+ const stopEstimate = new AbortController();
1255
+ const costLate = new AbortController();
1256
+ const costPanels = options.dashboardId
1257
+ ? writeCostPanels(
1258
+ next,
1259
+ queryChangedPanelIds(base, next, [...direct, ...affected]),
1260
+ )
1261
+ : [];
1262
+ const pendingCost = estimateWriteCost(
1263
+ costPanels,
1264
+ options.dashboardId,
1265
+ options.signal
1266
+ ? AbortSignal.any([options.signal, stopEstimate.signal])
1267
+ : stopEstimate.signal,
1268
+ costLate.signal,
1269
+ );
1027
1270
  const verification = await verifyDashboardPanels(
1028
1271
  next,
1029
1272
  [...direct, ...affected, ...visualIds],
@@ -1033,13 +1276,17 @@ export async function verifyPanelWrite(
1033
1276
  memo: options.memo,
1034
1277
  serverAuthoredPanelIds: options.serverAuthoredPanelIds,
1035
1278
  },
1036
- );
1279
+ ).catch((error: unknown) => {
1280
+ stopEstimate.abort();
1281
+ throw error;
1282
+ });
1037
1283
  const allowEmpty = options.allowEmptyResult === true;
1038
1284
  const directIds = new Set([...direct, ...visualIds]);
1039
1285
  const blockers = verification.panels.filter(
1040
1286
  (panel) => directIds.has(panel.panelId) && blocksSave(panel, allowEmpty),
1041
1287
  );
1042
1288
  if (blockers.length > 0 && options.mode !== "report") {
1289
+ stopEstimate.abort();
1043
1290
  fail(formatVerificationFailure(blockers), {
1044
1291
  errorCode: "dashboard_panel_verification_failed",
1045
1292
  statusCode: 422,
@@ -1064,9 +1311,11 @@ export async function verifyPanelWrite(
1064
1311
  .slice(0, 5)
1065
1312
  .map((panel) => panel.panelId)
1066
1313
  .join(", ")}${panels.length > 5 ? ` (+${panels.length - 5} more)` : ""}`;
1314
+ const cost = await settleWriteCost(pendingCost, costLate);
1067
1315
  return {
1068
1316
  verified,
1069
1317
  ...(visual.length > 0 ? { visualOnly: visual } : {}),
1318
+ ...(cost ? { cost } : {}),
1070
1319
  verification,
1071
1320
  proof: verification.panels
1072
1321
  .filter((panel) => panel.rowCount !== null)
@@ -1123,9 +1372,52 @@ export function verdictFields(
1123
1372
  ? { unverified: verdict.unverified }
1124
1373
  : {}),
1125
1374
  ...(verdict.nextStep ? { nextStep: verdict.nextStep } : {}),
1375
+ ...(verdict.cost ? { cost: verdict.cost } : {}),
1126
1376
  };
1127
1377
  }
1128
1378
 
1379
+ /**
1380
+ * "Cost of ...: ~X · $Y per refresh · ~$Z/month ...", or empty when no
1381
+ * changed panel reads BigQuery. Over a threshold it tells the agent to say so.
1382
+ */
1383
+ export function describeWriteCost(verdict: PanelWriteVerdict): string {
1384
+ const cost = verdict.cost;
1385
+ if (!cost) return "";
1386
+ const priced = cost.panels.filter((panel) => panel.status === "estimated");
1387
+ const unpriced = cost.panels.filter(
1388
+ (panel) => panel.status === "failed" || panel.status === "not_estimated",
1389
+ ).length;
1390
+ if (priced.length === 0) {
1391
+ return unpriced > 0
1392
+ ? `Cost: ${unpriced} changed panel(s) could not be priced; estimate-query-cost says why.`
1393
+ : "";
1394
+ }
1395
+ const { total } = cost;
1396
+ const subject =
1397
+ priced.length === 1
1398
+ ? `"${priced[0].title}"`
1399
+ : `the ${priced.length} changed panels that read BigQuery`;
1400
+ const monthly =
1401
+ total.usdPerMonth === null
1402
+ ? "no email reports scheduled"
1403
+ : `~${formatUsd(total.usdPerMonth)}/month for ${cost.runsPerMonthBasis}`;
1404
+ const over = [
1405
+ total.overPerRunThreshold &&
1406
+ `the ${formatUsd(QUERY_COST_THRESHOLDS.perRunUsd)}/run`,
1407
+ total.overPerMonthThreshold &&
1408
+ `the ${formatUsd(QUERY_COST_THRESHOLDS.perMonthUsd)}/month`,
1409
+ ].filter(Boolean);
1410
+ return [
1411
+ `Cost of ${subject} at default filters: ~${formatBytes(total.estimatedBytes)} · ${formatUsd(total.usdPerRun)} per refresh · ${monthly}.`,
1412
+ unpriced > 0 ? `${unpriced} more could not be priced.` : "",
1413
+ over.length > 0
1414
+ ? `That is over ${over.join(" and ")} threshold; tell the user this cost.`
1415
+ : "",
1416
+ ]
1417
+ .filter(Boolean)
1418
+ .join(" ");
1419
+ }
1420
+
1129
1421
  /** Says the viewer-visible edits no data check covered; empty when the edit touched no section or extension panel. */
1130
1422
  export function describeVisualOnly(verdict: PanelWriteVerdict): string {
1131
1423
  const changes = verdict.visualOnly ?? [];
@@ -1157,11 +1449,14 @@ export function annotateSummary(
1157
1449
  shown.length > 0
1158
1450
  ? `Verified: ${shown.join("; ")}${more > 0 ? ` (+${more} more)` : ""}.`
1159
1451
  : "No data panel was affected.";
1160
- return [summary, checked, visual].filter(Boolean).join(" ");
1452
+ return [summary, checked, visual, describeWriteCost(verdict)]
1453
+ .filter(Boolean)
1454
+ .join(" ");
1161
1455
  }
1162
1456
  return [
1163
1457
  `${options.saved === false ? "NOT VERIFIED" : "SAVED BUT NOT VERIFIED"}: ${summary} ${verdict.nextStep}`,
1164
1458
  visual,
1459
+ describeWriteCost(verdict),
1165
1460
  ]
1166
1461
  .filter(Boolean)
1167
1462
  .join(" ");
@@ -3,12 +3,14 @@ import path from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
4
 
5
5
  import firstPartyTemplateTrafficSeed from "../../seeds/dashboards/agent-native-templates-first-party.json" with { type: "json" };
6
+ import contentViewSeed from "../../seeds/dashboards/content-view.json" with { type: "json" };
6
7
  import googleAnalyticsSeed from "../../seeds/dashboards/google-analytics.json" with { type: "json" };
7
8
  import nodeExporterFullSeed from "../../seeds/dashboards/node-exporter-full.json" with { type: "json" };
8
9
  import skillsCliFunnelSeed from "../../seeds/dashboards/skills-cli-funnel.json" with { type: "json" };
9
10
  import { isSafeDashboardId } from "./dashboard-id";
10
11
 
11
12
  const shippedSeeds: Record<string, Record<string, unknown>> = {
13
+ "content-view": contentViewSeed as Record<string, unknown>,
12
14
  "agent-native-templates-first-party": firstPartyTemplateTrafficSeed as Record<
13
15
  string,
14
16
  unknown
@@ -929,6 +929,34 @@ export async function loadAccessibleDashboardMetadataByIds(
929
929
  );
930
930
  }
931
931
 
932
+ /**
933
+ * Owners of dashboards in one org, without a sharing check. Use it only to
934
+ * name who to ask for access; never return a title or config from it.
935
+ */
936
+ export async function loadOrgDashboardOwnersByIds(
937
+ orgId: string,
938
+ ids: readonly string[],
939
+ ): Promise<Array<{ id: string; kind: DashboardKind; ownerEmail: string }>> {
940
+ const uniqueIds = [
941
+ ...new Set(ids.map((id) => id.trim()).filter(Boolean)),
942
+ ].slice(0, MAX_DASHBOARD_METADATA_HYDRATION);
943
+ if (!uniqueIds.length) return [];
944
+
945
+ return getDb()
946
+ .select({
947
+ id: schema.dashboards.id,
948
+ kind: schema.dashboards.kind,
949
+ ownerEmail: schema.dashboards.ownerEmail,
950
+ })
951
+ .from(schema.dashboards)
952
+ .where(
953
+ and(
954
+ eq(schema.dashboards.orgId, orgId),
955
+ inArray(schema.dashboards.id, uniqueIds),
956
+ ),
957
+ );
958
+ }
959
+
932
960
  export type DashboardReviewScope =
933
961
  | { kind: "organization"; orgId: string }
934
962
  | { kind: "super-organization"; orgId: string };
@@ -1510,24 +1510,139 @@ function httpResponseExclusionEnd(
1510
1510
  }
1511
1511
  }
1512
1512
 
1513
+ type ConditionTokens = {
1514
+ tokens: AgentSqlToken[];
1515
+ depths: number[];
1516
+ sourceName: string;
1517
+ };
1518
+
1519
+ function widensCondition(tokens: AgentSqlToken[], index: number): boolean {
1520
+ const token = tokens[index];
1521
+ return (
1522
+ isWord(token, "or") ||
1523
+ isWord(token, "case") ||
1524
+ (isWord(token, "not") && !isWord(tokens[index - 1], "is"))
1525
+ );
1526
+ }
1527
+
1528
+ /** Whether some AND conjunct of tokens [from, to) at `level` drops every `http.response` row. */
1529
+ function conjunctionExcludesHttpResponse(
1530
+ condition: ConditionTokens,
1531
+ from: number,
1532
+ to: number,
1533
+ level: number,
1534
+ ): boolean {
1535
+ let start = from;
1536
+ let between = false;
1537
+ for (let index = from; index <= to; index++) {
1538
+ if (index < to) {
1539
+ if (condition.depths[index] !== level) continue;
1540
+ const token = condition.tokens[index];
1541
+ if (isWord(token, "between")) between = true;
1542
+ if (!isWord(token, "and")) continue;
1543
+ // BETWEEN's own AND joins its bounds, not two conditions.
1544
+ if (between) {
1545
+ between = false;
1546
+ continue;
1547
+ }
1548
+ }
1549
+ if (conjunctExcludesHttpResponse(condition, start, index, level)) {
1550
+ return true;
1551
+ }
1552
+ start = index + 1;
1553
+ }
1554
+ return false;
1555
+ }
1556
+
1557
+ function conjunctExcludesHttpResponse(
1558
+ condition: ConditionTokens,
1559
+ from: number,
1560
+ to: number,
1561
+ level: number,
1562
+ ): boolean {
1563
+ const { tokens, depths } = condition;
1564
+ if (
1565
+ to - from >= 2 &&
1566
+ tokens[from].text === "(" &&
1567
+ tokens[to - 1].text === ")"
1568
+ ) {
1569
+ // A subquery's event_name belongs to its own FROM, not to this source.
1570
+ if (
1571
+ isWord(tokens[from + 1], "select") ||
1572
+ isWord(tokens[from + 1], "with")
1573
+ ) {
1574
+ return false;
1575
+ }
1576
+ for (let index = from + 1; index < to - 1; index++) {
1577
+ if (depths[index] <= level) return false;
1578
+ }
1579
+ return groupExcludesHttpResponse(condition, from + 1, to - 1, level + 1);
1580
+ }
1581
+ const column = tokens[from + 1]?.text === "." ? from + 2 : from;
1582
+ return (
1583
+ isEventNameColumn(tokens, column, condition.sourceName) &&
1584
+ httpResponseExclusionEnd(tokens, column) === to
1585
+ );
1586
+ }
1587
+
1588
+ // A parenthesized condition excludes `http.response` only when every OR
1589
+ // branch does: one branch without an event restriction readmits it.
1590
+ function groupExcludesHttpResponse(
1591
+ condition: ConditionTokens,
1592
+ from: number,
1593
+ to: number,
1594
+ level: number,
1595
+ ): boolean {
1596
+ let start = from;
1597
+ for (let index = from; index <= to; index++) {
1598
+ if (index < to) {
1599
+ if (condition.depths[index] !== level) continue;
1600
+ if (!isWord(condition.tokens[index], "or")) {
1601
+ if (widensCondition(condition.tokens, index)) return false;
1602
+ continue;
1603
+ }
1604
+ }
1605
+ if (!conjunctionExcludesHttpResponse(condition, start, index, level)) {
1606
+ return false;
1607
+ }
1608
+ start = index + 1;
1609
+ }
1610
+ return true;
1611
+ }
1612
+
1513
1613
  /**
1514
1614
  * The model omits every `http.response` row, so a read can move to it only
1515
1615
  * when its own WHERE provably drops them: a top-level conjunct on
1516
- * `event_name`, with no OR or NOT in that WHERE that could let them back in.
1517
- * An unfiltered count or an `event_name` breakdown would otherwise lose those
1518
- * rows without any sign the number changed.
1616
+ * `event_name`, or a parenthesized OR whose every branch has one. ANDed
1617
+ * conditions only narrow, so OR and NOT may appear inside any other
1618
+ * parenthesized conjunct, but an OR, NOT, or CASE at the WHERE's own level
1619
+ * could let `http.response` back in. An unfiltered count or an
1620
+ * `event_name` breakdown would otherwise lose those rows without any sign the
1621
+ * number changed.
1519
1622
  */
1520
1623
  function eventsReadsIneligibility(
1521
1624
  sql: string,
1522
1625
  ): "reads_no_events" | "http_response_not_excluded" | null {
1523
1626
  const query = readAgentSqlQuery(sql, { dialect: "bigquery" });
1524
1627
  const { tokens } = query;
1628
+ // A pipe stage filters an earlier stage's result, so its WHERE says nothing
1629
+ // about which rows the source fed in.
1630
+ if (
1631
+ tokens.some(
1632
+ (token, index) =>
1633
+ token.text === "|>" ||
1634
+ (token.text === "|" && tokens[index + 1]?.text === ">"),
1635
+ )
1636
+ ) {
1637
+ return "http_response_not_excluded";
1638
+ }
1525
1639
  const depths: number[] = [];
1526
1640
  let depth = 0;
1641
+ // Array brackets nest expressions just as parentheses do.
1527
1642
  for (const token of tokens) {
1528
- if (token.text === ")") depth--;
1643
+ if (token.text === ")" || token.text === "]") depth--;
1529
1644
  depths.push(depth);
1530
- if (token.text === "(") depth++;
1645
+ if (token.text === "(" || token.text === "[") depth++;
1531
1646
  }
1532
1647
  const tokenIndex = new Map(
1533
1648
  tokens.map((token, index) => [token.start, index]),
@@ -1558,28 +1673,17 @@ function eventsReadsIneligibility(
1558
1673
  where++;
1559
1674
  }
1560
1675
  if (atLevelEnd(where)) return "http_response_not_excluded";
1561
- let excluded = false;
1562
- for (let index = where + 1; !atLevelEnd(index); index++) {
1563
- const token = tokens[index];
1564
- if (isWord(token, "or")) return "http_response_not_excluded";
1565
- if (isWord(token, "not") && !isWord(tokens[index - 1], "is")) {
1676
+ let end = where + 1;
1677
+ while (!atLevelEnd(end)) end++;
1678
+ for (let index = where + 1; index < end; index++) {
1679
+ if (depths[index] === level && widensCondition(tokens, index)) {
1566
1680
  return "http_response_not_excluded";
1567
1681
  }
1568
- if (
1569
- depths[index] !== level ||
1570
- !isEventNameColumn(tokens, index, sourceName)
1571
- ) {
1572
- continue;
1573
- }
1574
- const column = tokens[index - 1]?.text === "." ? index - 2 : index;
1575
- const before = tokens[column - 1];
1576
- if (!isWord(before, "where") && !isWord(before, "and")) continue;
1577
- const end = httpResponseExclusionEnd(tokens, index);
1578
- if (end !== -1 && (atLevelEnd(end) || isWord(tokens[end], "and"))) {
1579
- excluded = true;
1580
- }
1581
1682
  }
1582
- if (!excluded) return "http_response_not_excluded";
1683
+ const condition = { tokens, depths, sourceName };
1684
+ if (!conjunctionExcludesHttpResponse(condition, where + 1, end, level)) {
1685
+ return "http_response_not_excluded";
1686
+ }
1583
1687
  }
1584
1688
  return null;
1585
1689
  }