@agent-native/core-corpus 0.216.0 → 0.217.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 (149) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/templates/analytics/.agents/skills/session-replay/SKILL.md +61 -1
  3. package/corpus/templates/analytics/AGENTS.md +1 -1
  4. package/corpus/templates/analytics/CHANGELOG.md +1 -0
  5. package/corpus/templates/analytics/actions/get-session-replay-summary.ts +9 -65
  6. package/corpus/templates/analytics/actions/list-event-catalog.ts +1 -1
  7. package/corpus/templates/analytics/actions/list-session-outputs.ts +40 -0
  8. package/corpus/templates/analytics/actions/list-session-recordings.ts +17 -4
  9. package/corpus/templates/analytics/actions/view-screen.ts +29 -3
  10. package/corpus/templates/analytics/app/i18n/en-US.ts +27 -0
  11. package/corpus/templates/analytics/app/pages/sessions/EventCatalogPage.tsx +18 -1
  12. package/corpus/templates/analytics/app/pages/sessions/SessionDetailPage.tsx +3 -1
  13. package/corpus/templates/analytics/app/pages/sessions/SessionOutputs.tsx +197 -0
  14. package/corpus/templates/analytics/app/pages/sessions/SessionsTriagePage.tsx +114 -4
  15. package/corpus/templates/analytics/changelog/2026-10-15-sessions-can-find-people-who-made-a-slides-deck-but-didn-t-p.md +6 -0
  16. package/corpus/templates/analytics/changelog/2026-10-16-the-activation-funnel-counts-visitors-who-decline-analytics-.md +6 -0
  17. package/corpus/templates/analytics/server/db/schema.ts +67 -0
  18. package/corpus/templates/analytics/server/lib/canonical-first-party-dashboard-repair.ts +9 -0
  19. package/corpus/templates/analytics/server/lib/first-party-analytics.ts +28 -22
  20. package/corpus/templates/analytics/server/lib/first-party-metric-catalog.ts +36 -1
  21. package/corpus/templates/analytics/server/lib/output-outcomes.ts +712 -0
  22. package/corpus/templates/analytics/server/lib/replay-origins.ts +33 -0
  23. package/corpus/templates/analytics/server/lib/session-event-index.ts +20 -0
  24. package/corpus/templates/analytics/server/lib/session-performance.ts +6 -40
  25. package/corpus/templates/analytics/server/lib/session-recording-associations.ts +30 -1
  26. package/corpus/templates/analytics/server/lib/session-replay-triage.ts +99 -0
  27. package/corpus/templates/analytics/server/lib/session-replay.ts +45 -31
  28. package/corpus/templates/analytics/server/lib/sessions-triage-lab.ts +8 -2
  29. package/corpus/templates/analytics/server/plugins/db.ts +46 -0
  30. package/corpus/templates/analytics/shared/output-use.ts +205 -0
  31. package/corpus/templates/analytics/shared/session-events.ts +6 -0
  32. package/corpus/templates/calendar/CHANGELOG.md +6 -0
  33. package/corpus/templates/calendar/actions/create-event.ts +2 -4
  34. package/corpus/templates/calendar/actions/update-event.ts +35 -6
  35. package/corpus/templates/calendar/changelog/2026-10-21-solo-events-avoid-notification-approval.md +6 -0
  36. package/corpus/templates/community/qr/package.json +1 -1
  37. package/corpus/templates/content/.agents/skills/document-editing/references/databases.md +6 -3
  38. package/corpus/templates/content/CHANGELOG.md +8 -0
  39. package/corpus/templates/content/actions/_browser-document-save-attempt.ts +23 -1
  40. package/corpus/templates/content/actions/_builder-cms-blob-custody.ts +467 -0
  41. package/corpus/templates/content/actions/_builder-cms-execution-lease.ts +17 -0
  42. package/corpus/templates/content/actions/_builder-cms-intent-lookup.ts +5 -0
  43. package/corpus/templates/content/actions/_builder-cms-read-client.ts +881 -21
  44. package/corpus/templates/content/actions/_builder-cms-write-adapter.ts +28 -2
  45. package/corpus/templates/content/actions/_builder-cms-write-client.ts +109 -11
  46. package/corpus/templates/content/actions/_builder-docs-client.ts +1 -0
  47. package/corpus/templates/content/actions/_builder-private-blob.test-fixture.ts +90 -0
  48. package/corpus/templates/content/actions/_builder-source-execution-guard.ts +48 -0
  49. package/corpus/templates/content/actions/_content-save-outcomes.ts +5 -0
  50. package/corpus/templates/content/actions/_database-source-utils.ts +290 -8
  51. package/corpus/templates/content/actions/_database-utils.ts +93 -7
  52. package/corpus/templates/content/actions/_database-view-setup.ts +21 -2
  53. package/corpus/templates/content/actions/_delete-content-space.ts +8 -1
  54. package/corpus/templates/content/actions/_github-source-freshness.ts +30 -4
  55. package/corpus/templates/content/actions/add-content-database-source-field-property.ts +23 -6
  56. package/corpus/templates/content/actions/cancel-prepared-builder-source-update.ts +110 -26
  57. package/corpus/templates/content/actions/change-content-database-source-role.ts +51 -7
  58. package/corpus/templates/content/actions/delete-document.ts +41 -3
  59. package/corpus/templates/content/actions/disconnect-content-database-source.ts +68 -2
  60. package/corpus/templates/content/actions/disconnect-local-folder-source.ts +33 -2
  61. package/corpus/templates/content/actions/execute-builder-source-execution.ts +900 -140
  62. package/corpus/templates/content/actions/get-document.ts +2 -0
  63. package/corpus/templates/content/actions/permanently-delete-document.ts +6 -0
  64. package/corpus/templates/content/actions/prepare-builder-source-execution.ts +155 -13
  65. package/corpus/templates/content/actions/prepare-builder-source-review.ts +299 -73
  66. package/corpus/templates/content/actions/set-live-body-switch.ts +45 -0
  67. package/corpus/templates/content/actions/update-document.ts +139 -20
  68. package/corpus/templates/content/actions/validate-builder-source-execution.ts +135 -26
  69. package/corpus/templates/content/app/components/editor/database/DatabaseView.tsx +23 -0
  70. package/corpus/templates/content/app/components/editor/database/GitHubSourceStatusLine.tsx +27 -2
  71. package/corpus/templates/content/app/components/editor/database/ListItemLayout.tsx +32 -8
  72. package/corpus/templates/content/app/components/editor/database/ListItemRow.tsx +27 -2
  73. package/corpus/templates/content/app/components/editor/database-sources/BuilderSourceReviewDialog.tsx +1 -0
  74. package/corpus/templates/content/app/hooks/use-content-database.ts +40 -2
  75. package/corpus/templates/content/app/hooks/use-documents.ts +14 -0
  76. package/corpus/templates/content/app/i18n-data.ts +1 -0
  77. package/corpus/templates/content/changelog/2026-10-17-builder-content-avoids-retrying-a-write-when-builder-accepte.md +6 -0
  78. package/corpus/templates/content/changelog/2026-10-18-link-a-new-collection-to-a-github-folder-from-its-sources.md +1 -1
  79. package/corpus/templates/content/changelog/2026-10-20-builder-content-sync-keeps-read-and-write-status-accurate-wh.md +6 -0
  80. package/corpus/templates/content/changelog/2026-10-21-list-rows-can-show-each-pages-first-lines.md +6 -0
  81. package/corpus/templates/content/changelog/2026-10-22-a-collection-linked-to-a-big-github-folder-fills-faster-and-p.md +6 -0
  82. package/corpus/templates/content/docs/product/capabilities/content.source.adapters.md +29 -0
  83. package/corpus/templates/content/parity/matrix.md +42 -42
  84. package/corpus/templates/content/parity/matrix.ts +3 -1
  85. package/corpus/templates/content/server/db/schema.ts +20 -0
  86. package/corpus/templates/content/server/lib/content-trash-purge.ts +15 -1
  87. package/corpus/templates/content/server/lib/live-body-writer.ts +138 -26
  88. package/corpus/templates/content/server/live-copy/binding.ts +31 -0
  89. package/corpus/templates/content/server/live-copy/bounded-task.d.ts +6 -0
  90. package/corpus/templates/content/server/live-copy/exec-db.ts +1 -0
  91. package/corpus/templates/content/server/live-copy/holds.ts +59 -0
  92. package/corpus/templates/content/server/live-copy/persist.ts +656 -0
  93. package/corpus/templates/content/server/live-copy/switch.ts +136 -0
  94. package/corpus/templates/content/server/live-copy/task-names.ts +4 -0
  95. package/corpus/templates/content/server/live-copy/tasks.ts +183 -0
  96. package/corpus/templates/content/server/live-copy/write-cause.ts +85 -0
  97. package/corpus/templates/content/server/live-copy/write.ts +85 -0
  98. package/corpus/templates/content/server/plugins/builder-private-payload-cleanup.ts +22 -0
  99. package/corpus/templates/content/server/plugins/collab.ts +3 -0
  100. package/corpus/templates/content/server/plugins/db.ts +28 -0
  101. package/corpus/templates/content/shared/api.ts +30 -1
  102. package/corpus/templates/content/shared/database-item-layout.ts +54 -22
  103. package/corpus/templates/content/shared/document-intent-merge.ts +16 -9
  104. package/corpus/templates/content/shared/og-description.ts +258 -36
  105. package/corpus/templates/design/.agents/skills/design-templates/SKILL.md +5 -1
  106. package/corpus/templates/design/.generated/bridge/shader-runtime.generated.ts +25 -4
  107. package/corpus/templates/design/CHANGELOG.md +2 -0
  108. package/corpus/templates/design/actions/get-getting-started.ts +53 -0
  109. package/corpus/templates/design/app/components/design/DesignCanvas.tsx +10 -0
  110. package/corpus/templates/design/app/components/design/bridge/native-shader-runtime.bridge.ts +132 -79
  111. package/corpus/templates/design/app/components/design/bridge/native-source-provider.ts +268 -130
  112. package/corpus/templates/design/app/components/design/bridge/native-worker-scene-host.ts +122 -42
  113. package/corpus/templates/design/app/components/design/bridge/shader-runtime.bridge.ts +41 -4
  114. package/corpus/templates/design/app/components/design/design-canvas/text-content-change-result.ts +46 -12
  115. package/corpus/templates/design/app/components/home/DesignHomeLibrary.tsx +15 -8
  116. package/corpus/templates/design/app/components/layout/GettingStarted.tsx +50 -0
  117. package/corpus/templates/design/app/components/layout/Sidebar.tsx +3 -0
  118. package/corpus/templates/design/app/components/templates/DesignTemplateGrid.tsx +163 -0
  119. package/corpus/templates/design/app/components/templates/DesignTemplateLibrary.tsx +65 -78
  120. package/corpus/templates/design/app/components/templates/TemplatePreview.tsx +6 -2
  121. package/corpus/templates/design/app/global.css +6 -0
  122. package/corpus/templates/design/app/i18n/en-US.ts +17 -0
  123. package/corpus/templates/design/app/lib/canvas-load-outcome.ts +147 -0
  124. package/corpus/templates/design/app/lib/canvas-outcome-tracking.ts +531 -0
  125. package/corpus/templates/design/app/lib/canvas-style-kind.ts +50 -0
  126. package/corpus/templates/design/app/pages/Index.tsx +10 -3
  127. package/corpus/templates/design/app/pages/design-editor/commands/commit-visual-styles.ts +86 -29
  128. package/corpus/templates/design/app/pages/design-editor/commands/save-file-content.ts +38 -3
  129. package/corpus/templates/design/app/pages/design-editor/domains/use-editor-core.ts +9 -0
  130. package/corpus/templates/design/app/pages/design-editor/domains/use-editor-files-and-saving.ts +2 -0
  131. package/corpus/templates/design/changelog/2026-10-21-a-getting-started-checklist-at-the-bottom-of-the-sidebar-tra.md +6 -0
  132. package/corpus/templates/design/changelog/2026-10-21-templates-now-open-a-read-only-preview-from-the-card-and-the.md +6 -0
  133. package/corpus/templates/design/changelog/2026-10-22-collapsed-shader-layers-no-longer-interrupt-other-shaders-on.md +6 -0
  134. package/corpus/templates/design/changelog/2026-10-22-shader-fills-keep-rendering-when-other-screen-content-cannot.md +6 -0
  135. package/corpus/templates/design/scripts/design-export-validation/export-baseline.json +24 -0
  136. package/corpus/templates/design/server/lib/getting-started.ts +267 -0
  137. package/corpus/templates/design/server/plugins/getting-started.ts +5 -0
  138. package/corpus/templates/design/shared/design-template-presets/analytics-dashboard.ts +127 -0
  139. package/corpus/templates/design/shared/design-template-presets/design-studio.ts +98 -0
  140. package/corpus/templates/design/shared/design-template-presets/messaging-app.ts +101 -0
  141. package/corpus/templates/design/shared/design-template-presets/project-tasks.ts +120 -0
  142. package/corpus/templates/design/shared/design-template-presets.ts +8 -0
  143. package/corpus/templates/forms/server/plugins/agent-chat.ts +1 -1
  144. package/corpus/templates/slides/CHANGELOG.md +6 -0
  145. package/corpus/templates/slides/actions/delete-deck.ts +7 -0
  146. package/corpus/templates/slides/changelog/2026-10-21-connected-deck-deletion-requires-approval.md +6 -0
  147. package/corpus/templates/slides/scripts/edit-fidelity/client-action.ts +61 -0
  148. package/corpus/templates/slides/scripts/edit-fidelity/run.ts +10 -0
  149. package/package.json +1 -1
@@ -0,0 +1,33 @@
1
+ /** The origin of a URL, or null when it has none. */
2
+ export function normalizeReplayOrigin(
3
+ value: string | null | undefined,
4
+ ): string | null {
5
+ if (!value) return null;
6
+ try {
7
+ return new URL(value).origin;
8
+ // coercion-ok: a value that is not a URL names no origin, and callers treat null as "no origin".
9
+ } catch {
10
+ return null;
11
+ }
12
+ }
13
+
14
+ /** A replay key's allowed origins, stored as a JSON array or as comma- or newline-separated text. */
15
+ export function parseAllowedReplayOrigins(value: unknown): string[] {
16
+ if (Array.isArray(value)) {
17
+ return value
18
+ .map((item) =>
19
+ normalizeReplayOrigin(typeof item === "string" ? item.trim() : null),
20
+ )
21
+ .filter((item): item is string => Boolean(item));
22
+ }
23
+ if (typeof value !== "string" || !value.trim()) return [];
24
+ try {
25
+ return parseAllowedReplayOrigins(JSON.parse(value));
26
+ // coercion-ok: text that is not JSON is the comma- or newline-separated form.
27
+ } catch {
28
+ return value
29
+ .split(/[\n,]/)
30
+ .map((item) => normalizeReplayOrigin(item.trim()))
31
+ .filter((item): item is string => Boolean(item));
32
+ }
33
+ }
@@ -14,6 +14,7 @@ import {
14
14
  } from "drizzle-orm";
15
15
  import { alias } from "drizzle-orm/pg-core";
16
16
 
17
+ import { OUTPUT_MADE_EVENT, outputUseRule } from "../../shared/output-use.js";
17
18
  import {
18
19
  AUTOMATIC_ANALYTICS_EVENT_NAMES,
19
20
  type EventCatalogApp,
@@ -686,6 +687,18 @@ export async function listSessionEventNames(
686
687
  };
687
688
  }
688
689
 
690
+ function outputEventRole(
691
+ app: string | null,
692
+ eventName: string,
693
+ ): EventCatalogEntry["outputRole"] {
694
+ const rule = outputUseRule(app);
695
+ if (!rule) return null;
696
+ if (rule.madeEvents.includes(eventName)) return "made";
697
+ return rule.usedEvents.some((event) => event.eventName === eventName)
698
+ ? "used"
699
+ : null;
700
+ }
701
+
689
702
  export function isAutomaticAnalyticsEvent(eventName: string): boolean {
690
703
  return AUTOMATIC_ANALYTICS_EVENT_NAMES.has(eventName);
691
704
  }
@@ -798,6 +811,7 @@ export async function listEventCatalog(
798
811
  app: c.app,
799
812
  eventCount: sql<number>`count(distinct ${c.eventName})`,
800
813
  customEventCount: sql<number>`count(distinct ${c.eventName}) filter (where ${notInArray(c.eventName, [...AUTOMATIC_ANALYTICS_EVENT_NAMES])})`,
814
+ madeEventCount: sql<number>`count(*) filter (where ${eq(c.eventName, OUTPUT_MADE_EVENT)})`,
801
815
  volume: sql<number>`sum(${c.eventCount})`,
802
816
  })
803
817
  .from(c)
@@ -837,6 +851,7 @@ export async function listEventCatalog(
837
851
  description: null,
838
852
  automatic: isAutomaticAnalyticsEvent(row.eventName),
839
853
  stoppedFiring: false,
854
+ outputRole: outputEventRole(row.app, row.eventName),
840
855
  };
841
856
  },
842
857
  );
@@ -861,12 +876,17 @@ export async function listEventCatalog(
861
876
  app: string;
862
877
  eventCount: unknown;
863
878
  customEventCount: unknown;
879
+ madeEventCount: unknown;
864
880
  volume: unknown;
865
881
  }) => ({
866
882
  app: row.app || null,
867
883
  eventCount: Number(row.eventCount),
868
884
  volume: Number(row.volume),
869
885
  onlyAutomaticEvents: Number(row.customEventCount) === 0,
886
+ makesOutputs: Number(row.madeEventCount) > 0,
887
+ usedItEvents:
888
+ outputUseRule(row.app)?.usedEvents.map((event) => event.eventName) ??
889
+ [],
870
890
  }),
871
891
  );
872
892
 
@@ -1,17 +1,5 @@
1
1
  import { fail } from "@agent-native/core/action";
2
- import {
3
- and,
4
- asc,
5
- desc,
6
- eq,
7
- gte,
8
- inArray,
9
- lt,
10
- lte,
11
- ne,
12
- or,
13
- sql,
14
- } from "drizzle-orm";
2
+ import { and, desc, eq, gte, inArray, lt, lte, ne, or, sql } from "drizzle-orm";
15
3
  import { alias } from "drizzle-orm/pg-core";
16
4
 
17
5
  import {
@@ -50,7 +38,10 @@ import {
50
38
  viewerTenantKeys,
51
39
  warnIndexFailure,
52
40
  } from "./session-event-index.js";
53
- import { sessionRecordingAssociationsReady } from "./session-recording-associations.js";
41
+ import {
42
+ listRecordingSessionAssociations,
43
+ sessionRecordingAssociationsReady,
44
+ } from "./session-recording-associations.js";
54
45
 
55
46
  /**
56
47
  * Performance aggregates.
@@ -69,7 +60,6 @@ const MAX_ROUTE_LENGTH = 200;
69
60
  const MAX_APP_LENGTH = 100;
70
61
  const MAX_SAMPLE_WEIGHT = 10_000;
71
62
  const SESSION_PERFORMANCE_RETENTION_BUFFER_DAYS = 2;
72
- const ASSOCIATION_READ_PAGE_SIZE = 500;
73
63
  // Leave room for tenant and other filters under PostgreSQL's bind limit.
74
64
  const SESSION_PERFORMANCE_SESSION_BATCH_SIZE = 25_000;
75
65
  export const ROUTE_PERFORMANCE_RETENTION_DAYS = 180;
@@ -700,30 +690,6 @@ export async function getPerformanceCoverageStart(
700
690
  return starts.at(-1) ?? null;
701
691
  }
702
692
 
703
- async function recordingSessionAssociations(
704
- db: any,
705
- recordingIds: readonly string[],
706
- ): Promise<Array<{ recordingId: string; sessionId: string }>> {
707
- const association = schema.sessionRecordingSessionAssociations;
708
- const result: Array<{ recordingId: string; sessionId: string }> = [];
709
- let offset = 0;
710
- while (true) {
711
- const page = await db
712
- .select({
713
- recordingId: association.recordingId,
714
- sessionId: association.sessionId,
715
- })
716
- .from(association)
717
- .where(inArray(association.recordingId, [...recordingIds]))
718
- .orderBy(asc(association.recordingId), asc(association.sessionId))
719
- .limit(ASSOCIATION_READ_PAGE_SIZE)
720
- .offset(offset);
721
- result.push(...page);
722
- if (page.length < ASSOCIATION_READ_PAGE_SIZE) return result;
723
- offset += page.length;
724
- }
725
- }
726
-
727
693
  function mergePerformanceSummaries(
728
694
  first: SessionPerformanceSummary,
729
695
  second: SessionPerformanceSummary,
@@ -775,7 +741,7 @@ export async function getSessionPerformanceSummaries(
775
741
  const db = getDb() as any;
776
742
  if (!(await performanceTablesExist(db))) return summaries;
777
743
  const associationRows = (await sessionRecordingAssociationsReady(db))
778
- ? await recordingSessionAssociations(
744
+ ? await listRecordingSessionAssociations(
779
745
  db,
780
746
  recordings.map((recording) => recording.id),
781
747
  )
@@ -1,4 +1,8 @@
1
- import { sql } from "drizzle-orm";
1
+ import { asc, inArray, sql } from "drizzle-orm";
2
+
3
+ import { schema } from "../db/index.js";
4
+
5
+ const ASSOCIATION_READ_PAGE_SIZE = 500;
2
6
 
3
7
  const readyDatabases = new WeakSet<object>();
4
8
 
@@ -26,3 +30,28 @@ export async function sessionRecordingAssociationsReady(
26
30
  }
27
31
  throw new Error("Postgres table existence check returned an invalid value");
28
32
  }
33
+
34
+ /** Every session each recording observed, read in pages. */
35
+ export async function listRecordingSessionAssociations(
36
+ db: any,
37
+ recordingIds: readonly string[],
38
+ ): Promise<Array<{ recordingId: string; sessionId: string }>> {
39
+ const association = schema.sessionRecordingSessionAssociations;
40
+ const result: Array<{ recordingId: string; sessionId: string }> = [];
41
+ let offset = 0;
42
+ while (true) {
43
+ const page = await db
44
+ .select({
45
+ recordingId: association.recordingId,
46
+ sessionId: association.sessionId,
47
+ })
48
+ .from(association)
49
+ .where(inArray(association.recordingId, [...recordingIds]))
50
+ .orderBy(asc(association.recordingId), asc(association.sessionId))
51
+ .limit(ASSOCIATION_READ_PAGE_SIZE)
52
+ .offset(offset);
53
+ result.push(...page);
54
+ if (page.length < ASSOCIATION_READ_PAGE_SIZE) return result;
55
+ offset += page.length;
56
+ }
57
+ }
@@ -0,0 +1,99 @@
1
+ import { getSessionRecordingOutputs } from "./output-outcomes.js";
2
+ import { listRecordingFriction } from "./session-friction.js";
3
+ import {
4
+ getSessionRecordingPerformance,
5
+ type SessionRecordingSummary,
6
+ type SessionReplayScope,
7
+ } from "./session-replay.js";
8
+ import {
9
+ isSessionsTriageLabEnabled,
10
+ sessionsTriageReadFailure,
11
+ } from "./sessions-triage-lab.js";
12
+
13
+ /**
14
+ * One replay's summary plus what the Sessions triage Lab adds to its page:
15
+ * friction, speed, and outputs, each replaced by its error when the read
16
+ * fails. The summary action and `view-screen` both return this, so the agent
17
+ * reads what the replay page shows.
18
+ */
19
+ export async function withSessionsTriage(
20
+ summary: SessionRecordingSummary,
21
+ scope: SessionReplayScope,
22
+ logPrefix: string,
23
+ ) {
24
+ let labEnabled: boolean;
25
+ try {
26
+ labEnabled = await isSessionsTriageLabEnabled(
27
+ scope.userEmail,
28
+ scope.orgId ?? null,
29
+ );
30
+ } catch (error) {
31
+ return {
32
+ ...summary,
33
+ labStateError: sessionsTriageReadFailure("labState", logPrefix, error),
34
+ };
35
+ }
36
+ if (!labEnabled) return summary;
37
+ const [friction, speed, outputs] = await Promise.allSettled([
38
+ listRecordingFriction(scope, [summary.id]),
39
+ getSessionRecordingPerformance(scope, [summary.id]),
40
+ getSessionRecordingOutputs(scope, [summary.id]),
41
+ ]);
42
+ const recordingFriction =
43
+ friction.status === "fulfilled" ? friction.value[summary.id] : undefined;
44
+ const recordingSpeed =
45
+ speed.status === "fulfilled"
46
+ ? speed.value.performance[summary.id]
47
+ : undefined;
48
+ const recordingOutputs =
49
+ outputs.status === "fulfilled"
50
+ ? outputs.value.outputs[summary.id]
51
+ : undefined;
52
+ const omittedOutputs =
53
+ outputs.status === "fulfilled"
54
+ ? outputs.value.omittedOutputs[summary.id]
55
+ : undefined;
56
+ return {
57
+ ...summary,
58
+ ...(friction.status === "rejected"
59
+ ? {
60
+ frictionError: sessionsTriageReadFailure(
61
+ "friction",
62
+ logPrefix,
63
+ friction.reason,
64
+ ),
65
+ }
66
+ : recordingFriction
67
+ ? { friction: recordingFriction }
68
+ : { frictionError: "Friction read skipped this recording" }),
69
+ ...(speed.status === "rejected"
70
+ ? {
71
+ performanceError: sessionsTriageReadFailure(
72
+ "speed",
73
+ logPrefix,
74
+ speed.reason,
75
+ ),
76
+ }
77
+ : recordingSpeed !== undefined
78
+ ? {
79
+ performance: recordingSpeed,
80
+ performanceCoverageStartedAt: speed.value.coverageStartedAt,
81
+ }
82
+ : { performanceError: "Speed read skipped this recording" }),
83
+ ...(outputs.status === "rejected"
84
+ ? {
85
+ outputsError: sessionsTriageReadFailure(
86
+ "outputs",
87
+ logPrefix,
88
+ outputs.reason,
89
+ ),
90
+ }
91
+ : recordingOutputs !== undefined
92
+ ? {
93
+ outputs: recordingOutputs,
94
+ ...(omittedOutputs ? { omittedOutputs } : {}),
95
+ outputCoverageStartedAt: outputs.value.coverageStartedAt,
96
+ }
97
+ : { outputsError: "Outputs read skipped this recording" }),
98
+ };
99
+ }
@@ -42,6 +42,10 @@ import {
42
42
  type AnyColumn,
43
43
  } from "drizzle-orm";
44
44
 
45
+ import type {
46
+ OutputUseApp,
47
+ SessionRecordingOutputs,
48
+ } from "../../shared/output-use.js";
45
49
  import { isScreenshotSize } from "../../shared/png.js";
46
50
  import {
47
51
  isSessionFrictionSort,
@@ -66,7 +70,16 @@ import {
66
70
  } from "./first-party-analytics.js";
67
71
  import { MAX_SESSION_ID_LENGTH } from "./indexed-text.js";
68
72
  import type { JourneyRecording, RecordingViewport } from "./journey-tree.js";
73
+ import {
74
+ getSessionRecordingOutputs,
75
+ pruneOutputOutcomes,
76
+ unusedOutputConditions,
77
+ } from "./output-outcomes.js";
69
78
  import { canonicalReplayLinkTimestamp } from "./replay-link-timestamp.js";
79
+ import {
80
+ normalizeReplayOrigin,
81
+ parseAllowedReplayOrigins,
82
+ } from "./replay-origins.js";
70
83
  import { parseIngestBody } from "./request-errors.js";
71
84
  import {
72
85
  pruneSessionEventIndex,
@@ -161,6 +174,8 @@ export interface SessionReplayListFilters {
161
174
  includePerformance?: boolean;
162
175
  /** Measured sessions that showed every one of these friction signals. */
163
176
  frictionSignals?: SessionFrictionSignal[];
177
+ /** Sessions that made an output of this app and never used it in time. */
178
+ unused?: OutputUseApp;
164
179
  }
165
180
 
166
181
  export interface SessionReplayEventReadOptions {
@@ -407,34 +422,6 @@ function replayInteger(value: unknown): number | null {
407
422
  return null;
408
423
  }
409
424
 
410
- function normalizeReplayOrigin(
411
- value: string | null | undefined,
412
- ): string | null {
413
- if (!value) return null;
414
- try {
415
- return new URL(value).origin;
416
- } catch {
417
- return null;
418
- }
419
- }
420
-
421
- function parseAllowedReplayOrigins(value: unknown): string[] {
422
- if (Array.isArray(value)) {
423
- return value
424
- .map((item) => normalizeReplayOrigin(replayString(item)))
425
- .filter((item): item is string => Boolean(item));
426
- }
427
- if (typeof value !== "string" || !value.trim()) return [];
428
- try {
429
- return parseAllowedReplayOrigins(JSON.parse(value));
430
- } catch {
431
- return value
432
- .split(/[\n,]/)
433
- .map((item) => normalizeReplayOrigin(item.trim()))
434
- .filter((item): item is string => Boolean(item));
435
- }
436
- }
437
-
438
425
  function positiveReplayLimit(value: unknown, fallback: number): number {
439
426
  const parsed = replayInteger(value);
440
427
  return parsed && parsed > 0 ? parsed : fallback;
@@ -2488,6 +2475,13 @@ export interface SessionRecordingPage {
2488
2475
  * performance aggregates began, or null when they have not.
2489
2476
  */
2490
2477
  performanceCoverageStartedAt?: string | null;
2478
+ /**
2479
+ * With an unused-output filter: each row's outputs by recording id, and
2480
+ * when the viewer's output tracking began, or null before it has.
2481
+ */
2482
+ outputs?: SessionRecordingOutputs["outputs"];
2483
+ omittedOutputs?: SessionRecordingOutputs["omittedOutputs"];
2484
+ outputCoverageStartedAt?: string | null;
2491
2485
  }
2492
2486
 
2493
2487
  const personalEmailDomains = [...FREE_EMAIL_PROVIDER_DOMAINS];
@@ -2700,6 +2694,7 @@ export async function listSessionRecordingsPage(
2700
2694
  didNotEvents: filters.didNotEvents,
2701
2695
  })),
2702
2696
  ...(await slowSessionConditions(scope, filters.slow)),
2697
+ ...(await unusedOutputConditions(scope, filters.unused)),
2703
2698
  ...(await sessionFrictionFilterConditions(scope, filters.frictionSignals)),
2704
2699
  );
2705
2700
  const appConditions = [...conditions];
@@ -2778,18 +2773,32 @@ export async function listSessionRecordingsPage(
2778
2773
  const recordings: SessionRecordingSummary[] = rows.map((row: any) =>
2779
2774
  rowToSessionRecordingSummary(row),
2780
2775
  );
2781
- const performance =
2776
+ const [performance, outputs] = await Promise.all([
2782
2777
  filters.slow || filters.includePerformance
2783
- ? await sessionRecordingPerformance(scope, recordings, {
2778
+ ? sessionRecordingPerformance(scope, recordings, {
2784
2779
  summaries: filters.includePerformance === true,
2785
2780
  })
2786
- : null;
2781
+ : null,
2782
+ filters.unused
2783
+ ? getSessionRecordingOutputs(
2784
+ scope,
2785
+ recordings.map((recording) => recording.id),
2786
+ )
2787
+ : null,
2788
+ ]);
2787
2789
  return {
2788
2790
  recordings,
2789
2791
  total,
2790
2792
  ...(performance
2791
2793
  ? { performanceCoverageStartedAt: performance.coverageStartedAt }
2792
2794
  : {}),
2795
+ ...(outputs
2796
+ ? {
2797
+ outputs: outputs.outputs,
2798
+ omittedOutputs: outputs.omittedOutputs,
2799
+ outputCoverageStartedAt: outputs.coverageStartedAt,
2800
+ }
2801
+ : {}),
2793
2802
  appCounts: appRows
2794
2803
  .filter((row: { app: string | null }) => row.app)
2795
2804
  .map((row: { app: string; count: number }) => ({
@@ -3655,6 +3664,11 @@ export async function runSessionReplayRetentionSweep(
3655
3664
  } catch (err) {
3656
3665
  console.warn("[session-replay] Performance aggregate pruning failed:", err);
3657
3666
  }
3667
+ try {
3668
+ await pruneOutputOutcomes(replayRetentionDays(), now);
3669
+ } catch (err) {
3670
+ console.warn("[session-replay] Output outcome pruning failed:", err);
3671
+ }
3658
3672
  return {
3659
3673
  finalized: finalized.finalized,
3660
3674
  expired: expired.expired,
@@ -17,6 +17,7 @@ const READ_FAILURES = {
17
17
  labState: "Couldn't read the Sessions triage Lab state.",
18
18
  friction: "Couldn't read session friction.",
19
19
  speed: "Couldn't read session speed data.",
20
+ outputs: "Couldn't read session outputs.",
20
21
  } as const;
21
22
 
22
23
  /**
@@ -33,7 +34,11 @@ export function sessionsTriageReadFailure(
33
34
  }
34
35
 
35
36
  /** What a request asked the Lab for, named in its 403. */
36
- export type SessionsTriageLabFeature = "events" | "friction" | "speed";
37
+ export type SessionsTriageLabFeature =
38
+ | "events"
39
+ | "friction"
40
+ | "speed"
41
+ | "outputs";
37
42
 
38
43
  function labFeatureSubject(
39
44
  features: readonly SessionsTriageLabFeature[],
@@ -43,7 +48,8 @@ function labFeatureSubject(
43
48
  nouns.length < 3
44
49
  ? nouns.join(" and ")
45
50
  : `${nouns.slice(0, -1).join(", ")}, and ${nouns[nouns.length - 1]}`;
46
- const plural = nouns.length > 1 || nouns[0] === "events";
51
+ const plural =
52
+ nouns.length > 1 || nouns[0] === "events" || nouns[0] === "outputs";
47
53
  return `Session ${list} ${plural ? "are" : "is"}`;
48
54
  }
49
55
 
@@ -1847,6 +1847,52 @@ CREATE UNIQUE INDEX IF NOT EXISTS dashboard_views_default_per_dashboard_idx
1847
1847
  name: "analytics-content-coverage-config",
1848
1848
  sql: CONTENT_COVERAGE_CONFIG_MIGRATION_SQL,
1849
1849
  },
1850
+ {
1851
+ version: 182,
1852
+ name: "analytics-output-outcomes",
1853
+ sql: {
1854
+ // The coverage table comes last: its existence is how ingest and
1855
+ // reads know the other two exist.
1856
+ postgres: `CREATE TABLE IF NOT EXISTS analytics_output_outcomes (
1857
+ id TEXT PRIMARY KEY,
1858
+ tenant_key TEXT NOT NULL,
1859
+ owner_email TEXT NOT NULL,
1860
+ org_id TEXT,
1861
+ app TEXT NOT NULL,
1862
+ output_type TEXT NOT NULL,
1863
+ output_id TEXT NOT NULL,
1864
+ made_at TEXT,
1865
+ use_by TEXT,
1866
+ made_session_id TEXT,
1867
+ first_used_at TEXT,
1868
+ used_event TEXT,
1869
+ last_seen_at TEXT NOT NULL
1870
+ );
1871
+ CREATE UNIQUE INDEX IF NOT EXISTS analytics_output_outcomes_key_idx
1872
+ ON analytics_output_outcomes (tenant_key, app, output_id);
1873
+ CREATE INDEX IF NOT EXISTS analytics_output_outcomes_made_session_idx
1874
+ ON analytics_output_outcomes (tenant_key, made_session_id);
1875
+ CREATE INDEX IF NOT EXISTS analytics_output_outcomes_last_seen_idx
1876
+ ON analytics_output_outcomes (last_seen_at);
1877
+ CREATE TABLE IF NOT EXISTS analytics_output_outcome_gaps (
1878
+ id TEXT PRIMARY KEY,
1879
+ tenant_key TEXT NOT NULL,
1880
+ owner_email TEXT NOT NULL,
1881
+ org_id TEXT,
1882
+ first_at TEXT NOT NULL,
1883
+ last_at TEXT NOT NULL,
1884
+ recorded_at TEXT NOT NULL
1885
+ );
1886
+ CREATE INDEX IF NOT EXISTS analytics_output_outcome_gaps_tenant_idx
1887
+ ON analytics_output_outcome_gaps (tenant_key, last_at);
1888
+ CREATE TABLE IF NOT EXISTS analytics_output_outcome_coverage (
1889
+ tenant_key TEXT PRIMARY KEY,
1890
+ owner_email TEXT NOT NULL,
1891
+ org_id TEXT,
1892
+ started_at TEXT NOT NULL
1893
+ )`,
1894
+ },
1895
+ },
1850
1896
  ],
1851
1897
  { table: "analytics_migrations" },
1852
1898
  );