@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
@@ -15,12 +15,14 @@ import {
15
15
  import {
16
16
  CONTENT_DATABASE_ITEM_LAYOUT_PRESET_IDS,
17
17
  CONTENT_DATABASE_ITEM_LEADING_KINDS,
18
+ CONTENT_DATABASE_ITEM_LINE2_SOURCES,
18
19
  CONTENT_DATABASE_ITEM_TITLE_WEIGHTS,
19
20
  MAX_ITEM_LAYOUT_META_PROPERTIES,
20
21
  contentDatabaseItemLayoutPresets,
21
22
  contentDatabaseItemLayoutReferenceErrors,
22
23
  dropDeletedContentDatabaseItemLayoutReferences,
23
24
  itemLayoutLeadingCandidates,
25
+ itemLayoutLine2Candidates,
24
26
  normalizeContentDatabaseItemLayout,
25
27
  pruneContentDatabaseItemLayout,
26
28
  savableContentDatabaseItemLayout,
@@ -118,6 +120,7 @@ export const contentDatabaseItemLayoutSchema = z
118
120
  titleWeight: z.enum(CONTENT_DATABASE_ITEM_TITLE_WEIGHTS),
119
121
  line2: z
120
122
  .object({
123
+ source: z.enum(CONTENT_DATABASE_ITEM_LINE2_SOURCES).optional(),
121
124
  propertyId: itemLayoutPropertyIdSchema.nullable(),
122
125
  lines: itemLayoutLinesSchema,
123
126
  })
@@ -139,6 +142,7 @@ const itemLayoutPatchSchema = z
139
142
  titleWeight: z.enum(CONTENT_DATABASE_ITEM_TITLE_WEIGHTS).optional(),
140
143
  line2: z
141
144
  .object({
145
+ source: z.enum(CONTENT_DATABASE_ITEM_LINE2_SOURCES).optional(),
142
146
  propertyId: itemLayoutPropertyIdSchema.nullable().optional(),
143
147
  lines: itemLayoutLinesSchema.optional(),
144
148
  })
@@ -149,7 +153,7 @@ const itemLayoutPatchSchema = z
149
153
  })
150
154
  .strict()
151
155
  .describe(
152
- "List views only. Slots to change in each row; omitted slots keep their saved value. leading.kind checkbox or status uses the first property of that type unless propertyId names one. line2 shows a text, URL, or Blocks property; line3 and trailing list property IDs in order. Trailing values move under the title on narrow screens. Pass null to restore the default rows.",
156
+ "List views only. Slots to change in each row; omitted slots keep their saved value. leading.kind checkbox or status uses the first property of that type unless propertyId names one. line2 shows a text, URL, or Blocks property, or with source page the first lines of each row's page; line3 and trailing list property IDs in order. Trailing values move under the title on narrow screens. Pass null to restore the default rows.",
153
157
  );
154
158
 
155
159
  const listPatchFields = {
@@ -467,6 +471,17 @@ function nextListItemLayout(
467
471
  const ids = itemLayout[slot];
468
472
  if (ids) uniqueReferences(ids, slot);
469
473
  }
474
+ if (itemLayout.line2?.source === "page" && itemLayout.line2.propertyId) {
475
+ setupError(
476
+ "INVALID_VIEW_REFERENCE",
477
+ "line2.propertyId must be null or left out when line2.source is page.",
478
+ 400,
479
+ );
480
+ }
481
+ // Naming a property switches line 2 back from page text.
482
+ const line2Source =
483
+ itemLayout.line2?.source ??
484
+ (itemLayout.line2?.propertyId !== undefined ? "property" : undefined);
470
485
  const leading = itemLayout.leading && {
471
486
  kind: itemLayout.leading.kind,
472
487
  propertyId:
@@ -480,10 +495,14 @@ function nextListItemLayout(
480
495
  leading: leading ?? from.leading,
481
496
  titleWeight: itemLayout.titleWeight ?? from.titleWeight,
482
497
  line2: {
498
+ source: line2Source ?? from.line2.source,
483
499
  propertyId:
484
500
  itemLayout.line2?.propertyId !== undefined
485
501
  ? itemLayout.line2.propertyId
486
- : from.line2.propertyId,
502
+ : line2Source === "property" && from.line2.source === "page"
503
+ ? (itemLayoutLine2Candidates(properties)[0]?.definition.id ??
504
+ null)
505
+ : from.line2.propertyId,
487
506
  lines: itemLayout.line2?.lines ?? from.line2.lines,
488
507
  },
489
508
  line3: itemLayout.line3 ?? from.line3,
@@ -1,6 +1,7 @@
1
1
  import { and, eq } from "drizzle-orm";
2
2
 
3
3
  import { getDb, schema } from "../server/db/index.js";
4
+ import { cleanupBuilderPrivatePayload } from "./_builder-cms-blob-custody.js";
4
5
  import { resolveContentSpaceAccess } from "./_content-space-access.js";
5
6
  import {
6
7
  deleteDocumentRootsRecursive,
@@ -11,7 +12,8 @@ type Db = ReturnType<typeof getDb>;
11
12
  const MAX_DELETE_SCOPE_ATTEMPTS = 3;
12
13
 
13
14
  async function deleteUserContentSpaceOnce(db: Db, spaceId: string) {
14
- return db.transaction(async (tx) => {
15
+ const deletedBlobReferences = new Set<string>();
16
+ const result = await db.transaction(async (tx) => {
15
17
  const scopedDb = tx as unknown as Db;
16
18
  const access = await resolveContentSpaceAccess(spaceId, "editor", {
17
19
  db: scopedDb,
@@ -53,6 +55,7 @@ async function deleteUserContentSpaceOnce(db: Db, spaceId: string) {
53
55
  scopedDb,
54
56
  [mapping.documentId, filesDatabase.documentId],
55
57
  access.space.ownerEmail,
58
+ deletedBlobReferences,
56
59
  );
57
60
 
58
61
  const remainingDocuments = await scopedDb
@@ -79,6 +82,10 @@ async function deleteUserContentSpaceOnce(db: Db, spaceId: string) {
79
82
  deletedDocuments: deletedDocuments.length,
80
83
  };
81
84
  });
85
+ for (const reference of deletedBlobReferences) {
86
+ await cleanupBuilderPrivatePayload(reference, "deleted workspace source");
87
+ }
88
+ return result;
82
89
  }
83
90
 
84
91
  export async function deleteUserContentSpace(db: Db, spaceId: string) {
@@ -26,6 +26,12 @@ export const GITHUB_SOURCE_STALE_MS = 60_000;
26
26
  const READ_SYNC_BUDGET_MS = 3_000;
27
27
  /** One link's share of a background sync, which has 90 seconds in all. */
28
28
  const SWEEP_SYNC_BUDGET_MS = 20_000;
29
+ /**
30
+ * The share of a link partway through a push, like a big folder's first fill.
31
+ * It stops before the next background sync starts a minute later, so that one
32
+ * finds the link free and carries the push on.
33
+ */
34
+ const SWEEP_PUSH_BUDGET_MS = 50_000;
29
35
  /** Links the background sync checks at once. */
30
36
  const SWEEP_CONCURRENCY = 4;
31
37
  /** The background sync skips links a read or another sync checked this recently. */
@@ -274,10 +280,25 @@ export async function runGitHubSourceSweep(context: RecurringSweepContext) {
274
280
  asc(sql`coalesce(${schema.contentDatabaseSources.lastRefreshedAt}, '')`),
275
281
  )
276
282
  .limit(SWEEP_MAX_LINKS);
283
+ // A link partway through a push goes after the rest, so a big folder's
284
+ // first fill doesn't hold up a push to a small one. Those links take turns
285
+ // by when a run last moved their push, oldest first. The query's order
286
+ // can't do that: a run partway through isn't a check, so it leaves
287
+ // lastRefreshedAt as it was, and the same link would go first every time.
288
+ const carrying = new Set(
289
+ due
290
+ .map((link) => ({ link, state: linkState(link.metadataJson) }))
291
+ .filter(({ state }) => state?.pendingCommitSha)
292
+ .sort((a, b) =>
293
+ (a.state?.checkedAt ?? "").localeCompare(b.state?.checkedAt ?? ""),
294
+ )
295
+ .map(({ link }) => link),
296
+ );
297
+ const order = [...due.filter((link) => !carrying.has(link)), ...carrying];
277
298
  let next = 0;
278
299
  const worker = async () => {
279
- while (next < due.length) {
280
- const link = due[next++]!;
300
+ while (next < order.length) {
301
+ const link = order[next++]!;
281
302
  const remaining = context.deadlineAt - Date.now();
282
303
  if (context.signal?.aborted || remaining < 2_000) return;
283
304
  const owner = linkedBy(link);
@@ -288,7 +309,12 @@ export async function runGitHubSourceSweep(context: RecurringSweepContext) {
288
309
  () =>
289
310
  syncGitHubSource({
290
311
  sourceId: link.sourceId,
291
- budgetMs: Math.min(SWEEP_SYNC_BUDGET_MS, remaining - 1_000),
312
+ budgetMs: Math.min(
313
+ carrying.has(link)
314
+ ? SWEEP_PUSH_BUDGET_MS
315
+ : SWEEP_SYNC_BUDGET_MS,
316
+ remaining - 1_000,
317
+ ),
292
318
  signal: context.signal,
293
319
  }),
294
320
  );
@@ -303,6 +329,6 @@ export async function runGitHubSourceSweep(context: RecurringSweepContext) {
303
329
  }
304
330
  };
305
331
  await Promise.all(
306
- Array.from({ length: Math.min(SWEEP_CONCURRENCY, due.length) }, worker),
332
+ Array.from({ length: Math.min(SWEEP_CONCURRENCY, order.length) }, worker),
307
333
  );
308
334
  }
@@ -393,15 +393,23 @@ export default defineAction({
393
393
  throw new Error("The title source field is already mapped to Name.");
394
394
  }
395
395
 
396
- let federationRole: string | null = null;
396
+ let sourceMetadata: {
397
+ builderSpacePublicKey?: unknown;
398
+ connectionId?: unknown;
399
+ federation?: { role?: unknown };
400
+ } = {};
397
401
  try {
398
- const parsed = JSON.parse(source.metadataJson ?? "{}") as {
399
- federation?: { role?: string };
400
- };
401
- federationRole = parsed.federation?.role ?? null;
402
+ const parsed = JSON.parse(source.metadataJson ?? "{}") as unknown;
403
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
404
+ sourceMetadata = parsed as typeof sourceMetadata;
405
+ }
402
406
  } catch {
403
- federationRole = null;
407
+ sourceMetadata = {};
404
408
  }
409
+ const federationRole =
410
+ typeof sourceMetadata.federation?.role === "string"
411
+ ? sourceMetadata.federation.role
412
+ : null;
405
413
  const isSecondary = federationRole === "secondary";
406
414
 
407
415
  const materializeProperty = (
@@ -676,9 +684,18 @@ export default defineAction({
676
684
 
677
685
  const builderRead = await readBuilderCmsContentEntries({
678
686
  model: source.sourceTable,
687
+ expectedSourceSpace:
688
+ typeof sourceMetadata.builderSpacePublicKey === "string"
689
+ ? sourceMetadata.builderSpacePublicKey
690
+ : undefined,
691
+ expectedSourceConnectionId:
692
+ typeof sourceMetadata.connectionId === "string"
693
+ ? sourceMetadata.connectionId
694
+ : undefined,
679
695
  fieldPaths: [field.sourceFieldKey],
680
696
  limit: Math.max(BUILDER_FIELD_REFRESH_MINIMUM_LIMIT, error.rowCount),
681
697
  offset: 0,
698
+ requireBoundOAuth: true,
682
699
  });
683
700
  if (builderRead.state !== "live") {
684
701
  throw new Error(
@@ -1,7 +1,7 @@
1
- import { defineAction } from "@agent-native/core/action";
1
+ import { defineAction, fail } from "@agent-native/core/action";
2
2
  import { getRequestUserEmail } from "@agent-native/core/server/request-context";
3
3
  import { assertAccess } from "@agent-native/core/sharing";
4
- import { and, eq, inArray } from "drizzle-orm";
4
+ import { and, eq, inArray, isNull } from "drizzle-orm";
5
5
  import { z } from "zod";
6
6
 
7
7
  import { getDb, schema } from "../server/db/index.js";
@@ -9,6 +9,7 @@ import type {
9
9
  CancelPreparedBuilderSourceUpdateRequest,
10
10
  CancelPreparedBuilderSourceUpdateResponse,
11
11
  } from "../shared/api.js";
12
+ import { readBuilderExecutionPayload } from "./_builder-cms-blob-custody.js";
12
13
  import {
13
14
  CANCELLED_BUILDER_EXECUTION_SUMMARY,
14
15
  CANCELLED_BUILDER_REVIEW_NOTE_PREFIX,
@@ -45,28 +46,63 @@ function isLocalBlockedDryRun(payload: Record<string, unknown>) {
45
46
  );
46
47
  }
47
48
 
49
+ function isLivePreflightBlocked(payload: Record<string, unknown>) {
50
+ const preflight = payload.livePreflight;
51
+ if (!preflight || typeof preflight !== "object" || Array.isArray(preflight)) {
52
+ return false;
53
+ }
54
+ const record = preflight as Record<string, unknown>;
55
+ return (
56
+ typeof record.checkedAt === "string" &&
57
+ typeof record.exists === "boolean" &&
58
+ (record.lastUpdated === null ||
59
+ typeof record.lastUpdated === "number" ||
60
+ typeof record.lastUpdated === "string") &&
61
+ (record.blocksHash === null || typeof record.blocksHash === "string")
62
+ );
63
+ }
64
+
65
+ function isDefinitivelyRejectedWrite(payload: Record<string, unknown>) {
66
+ const response = payload.response;
67
+ if (!response || typeof response !== "object" || Array.isArray(response)) {
68
+ return false;
69
+ }
70
+ const record = response as Record<string, unknown>;
71
+ return (
72
+ record.ok === false &&
73
+ typeof record.status === "number" &&
74
+ Number.isInteger(record.status) &&
75
+ record.status >= 400 &&
76
+ record.status < 500
77
+ );
78
+ }
79
+
48
80
  function assertProvablyPreDispatch(execution: {
49
81
  state: string;
50
82
  summary: string;
51
83
  payloadJson: string;
52
84
  attemptToken: string | null;
53
85
  }) {
54
- if (!PRE_DISPATCH_STATES.has(execution.state)) {
86
+ const payload = parseExecutionPayload(execution.payloadJson);
87
+ const definitiveRejectedWrite =
88
+ (execution.state === "failed" || execution.state === "blocked") &&
89
+ isDefinitivelyRejectedWrite(payload);
90
+ if (!PRE_DISPATCH_STATES.has(execution.state) && !definitiveRejectedWrite) {
55
91
  throw new Error(
56
92
  `Cannot cancel a Builder execution in state ${execution.state}. Its outcome must be preserved.`,
57
93
  );
58
94
  }
59
- if (execution.attemptToken) {
95
+ if (execution.attemptToken && !definitiveRejectedWrite) {
60
96
  throw new Error(
61
97
  "Cannot cancel this Builder update because a dispatch attempt was recorded.",
62
98
  );
63
99
  }
64
100
 
65
- const payload = parseExecutionPayload(execution.payloadJson);
66
101
  if (
67
102
  Object.prototype.hasOwnProperty.call(payload, "response") ||
68
103
  Object.prototype.hasOwnProperty.call(payload, "dispatch")
69
104
  ) {
105
+ if (definitiveRejectedWrite) return;
70
106
  throw new Error(
71
107
  "Cannot cancel this Builder update because dispatch or response evidence was recorded.",
72
108
  );
@@ -74,7 +110,8 @@ function assertProvablyPreDispatch(execution: {
74
110
  if (
75
111
  execution.state === "blocked" &&
76
112
  execution.summary !== CANCELLED_BUILDER_EXECUTION_SUMMARY &&
77
- !isLocalBlockedDryRun(payload)
113
+ !isLocalBlockedDryRun(payload) &&
114
+ !isLivePreflightBlocked(payload)
78
115
  ) {
79
116
  throw new Error(
80
117
  "Cannot cancel this blocked Builder update because it is not provably a local pre-dispatch block.",
@@ -111,7 +148,7 @@ function assertCanonicalClaims(
111
148
 
112
149
  export default defineAction({
113
150
  description:
114
- "Cancel one exact prepared Builder update only when every execution is provably pre-dispatch. This never calls Builder or deletes audit history.",
151
+ "Cancel one exact prepared Builder update only when every execution never dispatched or Builder definitively rejected it. This never calls Builder or deletes audit history.",
115
152
  schema: z
116
153
  .object({
117
154
  databaseId: z.string().optional().describe("Collection ID"),
@@ -167,7 +204,7 @@ export default defineAction({
167
204
  .limit(1);
168
205
  if (!changeSet) throw new Error("Source change-set not found.");
169
206
 
170
- const executions = await tx
207
+ const storedExecutions = await tx
171
208
  .select()
172
209
  .from(schema.contentDatabaseSourceExecutions)
173
210
  .where(
@@ -179,6 +216,24 @@ export default defineAction({
179
216
  ),
180
217
  ),
181
218
  );
219
+ const executions = await Promise.all(
220
+ storedExecutions.map(async (execution) => ({
221
+ ...execution,
222
+ storedPayloadJson: execution.payloadJson,
223
+ payloadJson: JSON.stringify(
224
+ await readBuilderExecutionPayload({
225
+ payloadJson: execution.payloadJson,
226
+ binding: {
227
+ ownerEmail: execution.ownerEmail,
228
+ sourceId: execution.sourceId,
229
+ changeSetId: execution.changeSetId,
230
+ executionId: execution.id,
231
+ idempotencyKey: execution.idempotencyKey,
232
+ },
233
+ }),
234
+ ),
235
+ })),
236
+ );
182
237
  executionIds = executions.map((execution) => execution.id);
183
238
 
184
239
  if (executions.length === 0) {
@@ -253,23 +308,40 @@ export default defineAction({
253
308
  );
254
309
  }
255
310
 
256
- await tx
257
- .update(schema.contentDatabaseSourceExecutions)
258
- .set({
259
- state: "blocked",
260
- summary: CANCELLED_BUILDER_EXECUTION_SUMMARY,
261
- lastError: null,
262
- updatedAt: now,
263
- })
264
- .where(
265
- and(
266
- eq(schema.contentDatabaseSourceExecutions.sourceId, source.id),
267
- eq(
268
- schema.contentDatabaseSourceExecutions.changeSetId,
269
- changeSet.id,
311
+ for (const execution of executions) {
312
+ const [updated] = await tx
313
+ .update(schema.contentDatabaseSourceExecutions)
314
+ .set({
315
+ state: "blocked",
316
+ summary: CANCELLED_BUILDER_EXECUTION_SUMMARY,
317
+ attemptToken: null,
318
+ lastError: null,
319
+ updatedAt: now,
320
+ })
321
+ .where(
322
+ and(
323
+ eq(schema.contentDatabaseSourceExecutions.id, execution.id),
324
+ eq(schema.contentDatabaseSourceExecutions.state, execution.state),
325
+ eq(
326
+ schema.contentDatabaseSourceExecutions.payloadJson,
327
+ execution.storedPayloadJson,
328
+ ),
329
+ execution.attemptToken
330
+ ? eq(
331
+ schema.contentDatabaseSourceExecutions.attemptToken,
332
+ execution.attemptToken,
333
+ )
334
+ : isNull(schema.contentDatabaseSourceExecutions.attemptToken),
270
335
  ),
271
- ),
272
- );
336
+ )
337
+ .returning({ id: schema.contentDatabaseSourceExecutions.id });
338
+ if (!updated) {
339
+ fail(
340
+ "Cannot cancel this Builder update because an execution changed before cancellation committed.",
341
+ { errorCode: "builder_update_changed", statusCode: 409 },
342
+ );
343
+ }
344
+ }
273
345
 
274
346
  const noteSuffix = args.note?.trim() ? ` Note: ${args.note.trim()}` : "";
275
347
  await tx.insert(schema.contentDatabaseSourceChangeReviews).values({
@@ -284,10 +356,22 @@ export default defineAction({
284
356
  note: `${CANCELLED_BUILDER_REVIEW_NOTE_PREFIX} by ${actor} at ${now}.${noteSuffix}`,
285
357
  createdAt: now,
286
358
  });
287
- await tx
359
+ const [rejectedChangeSet] = await tx
288
360
  .update(schema.contentDatabaseSourceChangeSets)
289
361
  .set({ state: "rejected", updatedAt: now })
290
- .where(eq(schema.contentDatabaseSourceChangeSets.id, changeSet.id));
362
+ .where(
363
+ and(
364
+ eq(schema.contentDatabaseSourceChangeSets.id, changeSet.id),
365
+ eq(schema.contentDatabaseSourceChangeSets.state, changeSet.state),
366
+ ),
367
+ )
368
+ .returning({ id: schema.contentDatabaseSourceChangeSets.id });
369
+ if (!rejectedChangeSet) {
370
+ fail(
371
+ "Cannot cancel this Builder update because its review state changed before cancellation committed.",
372
+ { errorCode: "builder_update_changed", statusCode: 409 },
373
+ );
374
+ }
291
375
  await tx
292
376
  .update(schema.contentDatabaseSources)
293
377
  .set({ updatedAt: now })
@@ -1,4 +1,4 @@
1
- import { defineAction } from "@agent-native/core/action";
1
+ import { defineAction, fail } from "@agent-native/core/action";
2
2
  import { assertAccess } from "@agent-native/core/sharing";
3
3
  import { and, asc, eq, inArray, isNull, ne, sql } from "drizzle-orm";
4
4
  import { z } from "zod";
@@ -135,6 +135,33 @@ function sourceRole(source: SourceRecord): "primary" | "secondary" | null {
135
135
  }
136
136
  }
137
137
 
138
+ function builderSourceReadBinding(source: SourceRecord) {
139
+ let parsed: unknown;
140
+ try {
141
+ parsed = JSON.parse(source.metadataJson ?? "{}");
142
+ } catch {
143
+ fail("Builder source connection metadata is unreadable.", {
144
+ errorCode: "builder_source_binding_invalid",
145
+ statusCode: 412,
146
+ });
147
+ }
148
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
149
+ fail("Builder source connection metadata is unreadable.", {
150
+ errorCode: "builder_source_binding_invalid",
151
+ statusCode: 412,
152
+ });
153
+ }
154
+ const metadata = parsed as Record<string, unknown>;
155
+ return {
156
+ expectedSourceSpace:
157
+ typeof metadata.builderSpacePublicKey === "string"
158
+ ? metadata.builderSpacePublicKey
159
+ : null,
160
+ expectedSourceConnectionId:
161
+ typeof metadata.connectionId === "string" ? metadata.connectionId : null,
162
+ };
163
+ }
164
+
138
165
  async function clearSourceFederation(sourceId: string, now: string) {
139
166
  await mutateContentDatabaseSourceMetadata({
140
167
  sourceId,
@@ -235,6 +262,8 @@ export async function readBuilderCmsEntriesForRoleChange(
235
262
  args: {
236
263
  model: string;
237
264
  existingFieldPaths?: readonly string[];
265
+ expectedSourceSpace?: string | null;
266
+ expectedSourceConnectionId?: string | null;
238
267
  },
239
268
  dependencies: {
240
269
  readModelFields?: typeof readBuilderCmsModelFields;
@@ -247,7 +276,11 @@ export async function readBuilderCmsEntriesForRoleChange(
247
276
  let modelFields: BuilderCmsModelFieldSummary[] = [];
248
277
  let modelFieldsError: unknown = null;
249
278
  try {
250
- modelFields = await readModelFields({ model: args.model });
279
+ modelFields = await readModelFields({
280
+ model: args.model,
281
+ expectedSourceSpace: args.expectedSourceSpace,
282
+ expectedSourceConnectionId: args.expectedSourceConnectionId,
283
+ });
251
284
  } catch (error) {
252
285
  modelFieldsError = error;
253
286
  }
@@ -257,14 +290,23 @@ export async function readBuilderCmsEntriesForRoleChange(
257
290
  ...(args.existingFieldPaths ?? []),
258
291
  ...modelFields.map((field) => `data.${field.name}`),
259
292
  ],
293
+ expectedSourceSpace: args.expectedSourceSpace,
294
+ expectedSourceConnectionId: args.expectedSourceConnectionId,
295
+ requireBoundOAuth: true,
260
296
  });
261
297
  if (modelFieldsError) throw modelFieldsError;
298
+ if (read.state !== "live") {
299
+ throw new Error(
300
+ read.message ??
301
+ "Builder source could not be read for this role change; no source changes were made.",
302
+ );
303
+ }
262
304
  return { read, modelFields };
263
305
  }
264
306
 
265
307
  async function readSourceEntries(args: {
266
308
  sourceType: ContentDatabaseSourceType;
267
- sourceTable: string;
309
+ source: SourceRecord;
268
310
  limit: number;
269
311
  offset: number;
270
312
  }): Promise<{
@@ -276,7 +318,8 @@ async function readSourceEntries(args: {
276
318
  }> {
277
319
  if (args.sourceType === "builder-cms") {
278
320
  const { read, modelFields } = await readBuilderCmsEntriesForRoleChange({
279
- model: args.sourceTable,
321
+ model: args.source.sourceTable,
322
+ ...builderSourceReadBinding(args.source),
280
323
  });
281
324
  return {
282
325
  entries: read.state === "live" ? read.entries : [],
@@ -288,7 +331,7 @@ async function readSourceEntries(args: {
288
331
  }
289
332
  if (args.sourceType === "local-table") {
290
333
  const { entries, modelFields } = await readLocalTableEntries(
291
- args.sourceTable,
334
+ args.source.sourceTable,
292
335
  { limit: args.limit, offset: args.offset },
293
336
  );
294
337
  return {
@@ -302,7 +345,7 @@ async function readSourceEntries(args: {
302
345
  if (args.sourceType === "notion-database") {
303
346
  const read = await getContentDatabaseSourceAdapter("notion-database")!.read(
304
347
  {
305
- sourceTable: args.sourceTable,
348
+ sourceTable: args.source.sourceTable,
306
349
  limit: args.limit,
307
350
  offset: args.offset,
308
351
  },
@@ -372,7 +415,7 @@ export default defineAction({
372
415
 
373
416
  const { entries, modelFields } = await readSourceEntries({
374
417
  sourceType: normalizedType,
375
- sourceTable: source.sourceTable,
418
+ source,
376
419
  limit: args.limit ?? 100,
377
420
  offset: args.offset ?? 0,
378
421
  });
@@ -433,6 +476,7 @@ export default defineAction({
433
476
  existingFieldPaths: existingSourceFields.map(
434
477
  (field) => field.sourceFieldKey,
435
478
  ),
479
+ ...builderSourceReadBinding(source),
436
480
  });
437
481
  const entries = read.state === "live" ? read.entries : [];
438
482
  await db
@@ -15,6 +15,13 @@ import { documentChangeResource } from "../server/lib/document-change-resource.j
15
15
  import { deleteDocumentImports } from "../server/lib/document-imports.js";
16
16
  import { chunks } from "./_batch-utils.js";
17
17
  import { deleteBlocksFieldIdentity } from "./_blocks-field-identity.js";
18
+ import {
19
+ builderExecutionPayloadReference,
20
+ cleanupBuilderPrivatePayload,
21
+ enqueueBuilderPrivatePayloadCleanup,
22
+ isBuilderPrivatePayloadBoundToSource,
23
+ } from "./_builder-cms-blob-custody.js";
24
+ import { assertNoActiveBuilderSourceExecutions } from "./_builder-source-execution-guard.js";
18
25
  import {
19
26
  lockContentDatabaseMutation,
20
27
  touchContentDatabase,
@@ -972,19 +979,26 @@ export async function deleteDocumentRecursive(
972
979
  id: string,
973
980
  ownerEmail: string,
974
981
  ): Promise<string[]> {
975
- return db.transaction((tx) =>
982
+ const deletedBlobReferences = new Set<string>();
983
+ const deleted = await db.transaction((tx) =>
976
984
  deleteDocumentRootsRecursive(
977
985
  tx as unknown as ReturnType<typeof getDb>,
978
986
  [id],
979
987
  ownerEmail,
988
+ deletedBlobReferences,
980
989
  ),
981
990
  );
991
+ for (const reference of deletedBlobReferences) {
992
+ await cleanupBuilderPrivatePayload(reference, "deleted document source");
993
+ }
994
+ return deleted;
982
995
  }
983
996
 
984
997
  export async function deleteDocumentRootsRecursive(
985
998
  db: ReturnType<typeof getDb>,
986
999
  rootIds: string[],
987
1000
  ownerEmail: string,
1001
+ deletedBlobReferences: Set<string>,
988
1002
  ): Promise<string[]> {
989
1003
  const collectScope = () =>
990
1004
  collectDocumentSubtreeForDelete(db, rootIds, ownerEmail);
@@ -998,6 +1012,7 @@ export async function deleteDocumentRootsRecursive(
998
1012
  documentIds,
999
1013
  ownedDatabaseIds,
1000
1014
  ownerEmail,
1015
+ deletedBlobReferences,
1001
1016
  );
1002
1017
  }
1003
1018
 
@@ -1006,6 +1021,7 @@ async function deleteCollectedDocuments(
1006
1021
  documentIds: string[],
1007
1022
  ownedDatabaseIds: string[],
1008
1023
  ownerEmail: string,
1024
+ deletedBlobReferences: Set<string>,
1009
1025
  ): Promise<string[]> {
1010
1026
  await assertNotWorkspaceCatalogDocuments(db, documentIds, "deleted");
1011
1027
 
@@ -1041,6 +1057,7 @@ async function deleteCollectedDocuments(
1041
1057
  });
1042
1058
 
1043
1059
  await deleteWhereIn(sourceIds, async (sourceIdBatch) => {
1060
+ await assertNoActiveBuilderSourceExecutions(db, sourceIdBatch);
1044
1061
  await db
1045
1062
  .delete(schema.contentDatabaseBodyHydrationQueue)
1046
1063
  .where(
@@ -1049,11 +1066,30 @@ async function deleteCollectedDocuments(
1049
1066
  sourceIdBatch,
1050
1067
  ),
1051
1068
  );
1052
- await db
1069
+ const deletedExecutions = await db
1053
1070
  .delete(schema.contentDatabaseSourceExecutions)
1054
1071
  .where(
1055
1072
  inArray(schema.contentDatabaseSourceExecutions.sourceId, sourceIdBatch),
1056
- );
1073
+ )
1074
+ .returning({
1075
+ ownerEmail: schema.contentDatabaseSourceExecutions.ownerEmail,
1076
+ sourceId: schema.contentDatabaseSourceExecutions.sourceId,
1077
+ payloadJson: schema.contentDatabaseSourceExecutions.payloadJson,
1078
+ });
1079
+ for (const row of deletedExecutions) {
1080
+ const reference = builderExecutionPayloadReference(row.payloadJson);
1081
+ if (
1082
+ reference &&
1083
+ isBuilderPrivatePayloadBoundToSource(
1084
+ reference,
1085
+ row.ownerEmail,
1086
+ row.sourceId,
1087
+ )
1088
+ ) {
1089
+ deletedBlobReferences.add(reference);
1090
+ await enqueueBuilderPrivatePayloadCleanup(db, reference);
1091
+ }
1092
+ }
1057
1093
  await db
1058
1094
  .delete(schema.contentDatabaseSourceChangeReviews)
1059
1095
  .where(
@@ -1225,6 +1261,7 @@ export async function deleteTrashedDocumentSubtree(
1225
1261
  db: ReturnType<typeof getDb>,
1226
1262
  id: string,
1227
1263
  ownerEmail: string,
1264
+ deletedBlobReferences: Set<string>,
1228
1265
  frozen?: ReadonlyArray<{
1229
1266
  documentId: string;
1230
1267
  expectedTrashedAt: string;
@@ -1374,6 +1411,7 @@ export async function deleteTrashedDocumentSubtree(
1374
1411
  documentIds,
1375
1412
  ownedDatabaseIds,
1376
1413
  ownerEmail,
1414
+ deletedBlobReferences,
1377
1415
  );
1378
1416
  }
1379
1417