@agent-native/core-corpus 0.216.0 → 0.218.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 (196) 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 +3 -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/components/layout/Sidebar.tsx +38 -167
  11. package/corpus/templates/analytics/app/i18n/en-US.ts +27 -0
  12. package/corpus/templates/analytics/app/pages/sessions/EventCatalogPage.tsx +18 -1
  13. package/corpus/templates/analytics/app/pages/sessions/SessionDetailPage.tsx +3 -1
  14. package/corpus/templates/analytics/app/pages/sessions/SessionOutputs.tsx +197 -0
  15. package/corpus/templates/analytics/app/pages/sessions/SessionsTriagePage.tsx +114 -4
  16. package/corpus/templates/analytics/changelog/2026-10-15-sessions-can-find-people-who-made-a-slides-deck-but-didn-t-p.md +6 -0
  17. package/corpus/templates/analytics/changelog/2026-10-16-owned-and-shared-chats-appear-in-separate-history-sections.md +6 -0
  18. package/corpus/templates/analytics/changelog/2026-10-16-the-activation-funnel-counts-visitors-who-decline-analytics-.md +6 -0
  19. package/corpus/templates/analytics/server/db/schema.ts +67 -0
  20. package/corpus/templates/analytics/server/lib/canonical-first-party-dashboard-repair.ts +9 -0
  21. package/corpus/templates/analytics/server/lib/first-party-analytics.ts +28 -22
  22. package/corpus/templates/analytics/server/lib/first-party-metric-catalog.ts +36 -1
  23. package/corpus/templates/analytics/server/lib/output-outcomes.ts +712 -0
  24. package/corpus/templates/analytics/server/lib/replay-origins.ts +33 -0
  25. package/corpus/templates/analytics/server/lib/session-event-index.ts +20 -0
  26. package/corpus/templates/analytics/server/lib/session-performance.ts +6 -40
  27. package/corpus/templates/analytics/server/lib/session-recording-associations.ts +30 -1
  28. package/corpus/templates/analytics/server/lib/session-replay-triage.ts +99 -0
  29. package/corpus/templates/analytics/server/lib/session-replay.ts +45 -31
  30. package/corpus/templates/analytics/server/lib/sessions-triage-lab.ts +8 -2
  31. package/corpus/templates/analytics/server/plugins/db.ts +46 -0
  32. package/corpus/templates/analytics/shared/output-use.ts +205 -0
  33. package/corpus/templates/analytics/shared/session-events.ts +6 -0
  34. package/corpus/templates/assets/CHANGELOG.md +6 -0
  35. package/corpus/templates/assets/app/components/layout/Sidebar.tsx +51 -117
  36. package/corpus/templates/assets/changelog/2026-10-16-owned-and-shared-chats-appear-in-separate-history-sections.md +6 -0
  37. package/corpus/templates/brain/app/components/layout/Sidebar.tsx +34 -101
  38. package/corpus/templates/brain/app/routes/sources.tsx +1 -1
  39. package/corpus/templates/brain/vitest.config.ts +7 -0
  40. package/corpus/templates/calendar/CHANGELOG.md +12 -0
  41. package/corpus/templates/calendar/actions/create-event.ts +2 -4
  42. package/corpus/templates/calendar/actions/update-event.ts +35 -6
  43. package/corpus/templates/calendar/app/pages/BookingLinksPage.tsx +1 -1
  44. package/corpus/templates/calendar/changelog/2026-10-16-booking-links-show-localized-visibility-badges.md +6 -0
  45. package/corpus/templates/calendar/changelog/2026-10-21-solo-events-avoid-notification-approval.md +6 -0
  46. package/corpus/templates/chat/app/components/layout/Sidebar.tsx +62 -101
  47. package/corpus/templates/clips/CHANGELOG.md +7 -20
  48. package/corpus/templates/clips/app/components/meetings/share-meeting-dialog.tsx +20 -77
  49. package/corpus/templates/clips/app/components/player/share-dialog.tsx +63 -90
  50. package/corpus/templates/clips/app/components/sharing/share-test-support.tsx +180 -0
  51. package/corpus/templates/clips/app/components/sharing/share-ui.tsx +5 -584
  52. package/corpus/templates/clips/changelog/2026-10-16-share-dialogs-make-it-easier-to-see-who-has-access-and-manag.md +6 -0
  53. package/corpus/templates/clips/changelog/2026-10-21-share-panels-describe-general-access-in-a-sentence.md +6 -0
  54. package/corpus/templates/community/qr/package.json +1 -1
  55. package/corpus/templates/content/.agents/skills/document-editing/references/databases.md +6 -3
  56. package/corpus/templates/content/CHANGELOG.md +10 -0
  57. package/corpus/templates/content/actions/_browser-document-save-attempt.ts +23 -1
  58. package/corpus/templates/content/actions/_builder-cms-blob-custody.ts +467 -0
  59. package/corpus/templates/content/actions/_builder-cms-execution-lease.ts +17 -0
  60. package/corpus/templates/content/actions/_builder-cms-intent-lookup.ts +5 -0
  61. package/corpus/templates/content/actions/_builder-cms-read-client.ts +881 -21
  62. package/corpus/templates/content/actions/_builder-cms-write-adapter.ts +28 -2
  63. package/corpus/templates/content/actions/_builder-cms-write-client.ts +109 -11
  64. package/corpus/templates/content/actions/_builder-docs-client.ts +1 -0
  65. package/corpus/templates/content/actions/_builder-private-blob.test-fixture.ts +90 -0
  66. package/corpus/templates/content/actions/_builder-source-execution-guard.ts +48 -0
  67. package/corpus/templates/content/actions/_content-save-outcomes.ts +5 -0
  68. package/corpus/templates/content/actions/_database-source-utils.ts +290 -8
  69. package/corpus/templates/content/actions/_database-utils.ts +93 -7
  70. package/corpus/templates/content/actions/_database-view-setup.ts +21 -2
  71. package/corpus/templates/content/actions/_delete-content-space.ts +8 -1
  72. package/corpus/templates/content/actions/_github-source-freshness.ts +30 -4
  73. package/corpus/templates/content/actions/add-content-database-source-field-property.ts +23 -6
  74. package/corpus/templates/content/actions/cancel-prepared-builder-source-update.ts +110 -26
  75. package/corpus/templates/content/actions/change-content-database-source-role.ts +51 -7
  76. package/corpus/templates/content/actions/delete-document.ts +41 -3
  77. package/corpus/templates/content/actions/disconnect-content-database-source.ts +68 -2
  78. package/corpus/templates/content/actions/disconnect-local-folder-source.ts +33 -2
  79. package/corpus/templates/content/actions/execute-builder-source-execution.ts +900 -140
  80. package/corpus/templates/content/actions/get-document.ts +2 -0
  81. package/corpus/templates/content/actions/permanently-delete-document.ts +6 -0
  82. package/corpus/templates/content/actions/prepare-builder-source-execution.ts +155 -13
  83. package/corpus/templates/content/actions/prepare-builder-source-review.ts +299 -73
  84. package/corpus/templates/content/actions/set-live-body-switch.ts +45 -0
  85. package/corpus/templates/content/actions/update-document.ts +139 -20
  86. package/corpus/templates/content/actions/validate-builder-source-execution.ts +135 -26
  87. package/corpus/templates/content/app/components/editor/CommentsSidebar.tsx +119 -30
  88. package/corpus/templates/content/app/components/editor/DocumentEditor.tsx +56 -13
  89. package/corpus/templates/content/app/components/editor/SuggestionText.tsx +18 -3
  90. package/corpus/templates/content/app/components/editor/VisualEditor.tsx +89 -22
  91. package/corpus/templates/content/app/components/editor/database/DatabaseView.tsx +23 -0
  92. package/corpus/templates/content/app/components/editor/database/GitHubSourceStatusLine.tsx +27 -2
  93. package/corpus/templates/content/app/components/editor/database/ListItemLayout.tsx +32 -8
  94. package/corpus/templates/content/app/components/editor/database/ListItemRow.tsx +27 -2
  95. package/corpus/templates/content/app/components/editor/database-sources/BuilderSourceReviewDialog.tsx +1 -0
  96. package/corpus/templates/content/app/components/editor/extensions/SuggestionHighlight.ts +383 -34
  97. package/corpus/templates/content/app/components/editor/suggestions/draft-session.ts +31 -0
  98. package/corpus/templates/content/app/components/editor/suggestions/presentation-rebase.ts +82 -4
  99. package/corpus/templates/content/app/global.css +31 -0
  100. package/corpus/templates/content/app/hooks/use-content-database.ts +40 -2
  101. package/corpus/templates/content/app/hooks/use-documents.ts +14 -0
  102. package/corpus/templates/content/app/i18n-data.ts +1 -0
  103. package/corpus/templates/content/changelog/2026-10-16-accepting-a-suggestion-on-a-page-that-changed-since-it-was-s.md +6 -0
  104. package/corpus/templates/content/changelog/2026-10-17-builder-content-avoids-retrying-a-write-when-builder-accepte.md +6 -0
  105. package/corpus/templates/content/changelog/2026-10-18-link-a-new-collection-to-a-github-folder-from-its-sources.md +1 -1
  106. package/corpus/templates/content/changelog/2026-10-20-builder-content-sync-keeps-read-and-write-status-accurate-wh.md +6 -0
  107. package/corpus/templates/content/changelog/2026-10-20-suggested-edits-that-add-remove-split-or-merge-paragraphs-li.md +6 -0
  108. package/corpus/templates/content/changelog/2026-10-21-list-rows-can-show-each-pages-first-lines.md +6 -0
  109. package/corpus/templates/content/changelog/2026-10-22-a-collection-linked-to-a-big-github-folder-fills-faster-and-p.md +6 -0
  110. package/corpus/templates/content/docs/product/capabilities/content.source.adapters.md +29 -0
  111. package/corpus/templates/content/parity/matrix.md +42 -42
  112. package/corpus/templates/content/parity/matrix.ts +3 -1
  113. package/corpus/templates/content/server/db/schema.ts +20 -0
  114. package/corpus/templates/content/server/lib/content-trash-purge.ts +15 -1
  115. package/corpus/templates/content/server/lib/live-body-writer.ts +138 -26
  116. package/corpus/templates/content/server/live-copy/binding.ts +31 -0
  117. package/corpus/templates/content/server/live-copy/bounded-task.d.ts +6 -0
  118. package/corpus/templates/content/server/live-copy/exec-db.ts +1 -0
  119. package/corpus/templates/content/server/live-copy/holds.ts +59 -0
  120. package/corpus/templates/content/server/live-copy/persist.ts +656 -0
  121. package/corpus/templates/content/server/live-copy/switch.ts +136 -0
  122. package/corpus/templates/content/server/live-copy/task-names.ts +4 -0
  123. package/corpus/templates/content/server/live-copy/tasks.ts +183 -0
  124. package/corpus/templates/content/server/live-copy/write-cause.ts +85 -0
  125. package/corpus/templates/content/server/live-copy/write.ts +85 -0
  126. package/corpus/templates/content/server/plugins/builder-private-payload-cleanup.ts +22 -0
  127. package/corpus/templates/content/server/plugins/collab.ts +3 -0
  128. package/corpus/templates/content/server/plugins/db.ts +28 -0
  129. package/corpus/templates/content/shared/api.ts +30 -1
  130. package/corpus/templates/content/shared/database-item-layout.ts +54 -22
  131. package/corpus/templates/content/shared/document-intent-merge.ts +16 -9
  132. package/corpus/templates/content/shared/og-description.ts +258 -36
  133. package/corpus/templates/content/shared/suggestion-diff.ts +23 -6
  134. package/corpus/templates/content/shared/suggestion-formatting.ts +44 -14
  135. package/corpus/templates/content/shared/suggestion-text.ts +20 -5
  136. package/corpus/templates/design/.agents/skills/design-templates/SKILL.md +5 -1
  137. package/corpus/templates/design/.generated/bridge/shader-runtime.generated.ts +25 -4
  138. package/corpus/templates/design/CHANGELOG.md +5 -0
  139. package/corpus/templates/design/actions/get-getting-started.ts +53 -0
  140. package/corpus/templates/design/app/components/design/DesignCanvas.tsx +10 -0
  141. package/corpus/templates/design/app/components/design/bridge/native-shader-runtime.bridge.ts +132 -79
  142. package/corpus/templates/design/app/components/design/bridge/native-source-provider.ts +268 -130
  143. package/corpus/templates/design/app/components/design/bridge/native-worker-scene-host.ts +122 -42
  144. package/corpus/templates/design/app/components/design/bridge/shader-runtime.bridge.ts +41 -4
  145. package/corpus/templates/design/app/components/design/design-canvas/text-content-change-result.ts +46 -12
  146. package/corpus/templates/design/app/components/home/DesignHomeLibrary.tsx +15 -8
  147. package/corpus/templates/design/app/components/layout/GettingStarted.tsx +50 -0
  148. package/corpus/templates/design/app/components/layout/Sidebar.tsx +3 -0
  149. package/corpus/templates/design/app/components/templates/DesignTemplateGrid.tsx +163 -0
  150. package/corpus/templates/design/app/components/templates/DesignTemplateLibrary.tsx +65 -78
  151. package/corpus/templates/design/app/components/templates/TemplatePreview.tsx +6 -2
  152. package/corpus/templates/design/app/global.css +6 -0
  153. package/corpus/templates/design/app/i18n/en-US.ts +17 -0
  154. package/corpus/templates/design/app/lib/canvas-load-outcome.ts +147 -0
  155. package/corpus/templates/design/app/lib/canvas-outcome-tracking.ts +531 -0
  156. package/corpus/templates/design/app/lib/canvas-style-kind.ts +50 -0
  157. package/corpus/templates/design/app/pages/DesignSystems.tsx +1 -1
  158. package/corpus/templates/design/app/pages/Index.tsx +28 -67
  159. package/corpus/templates/design/app/pages/design-editor/commands/commit-visual-styles.ts +86 -29
  160. package/corpus/templates/design/app/pages/design-editor/commands/save-file-content.ts +38 -3
  161. package/corpus/templates/design/app/pages/design-editor/domains/use-editor-core.ts +9 -0
  162. package/corpus/templates/design/app/pages/design-editor/domains/use-editor-files-and-saving.ts +2 -0
  163. package/corpus/templates/design/changelog/2026-10-16-design-pages-use-localized-visibility-badges-and-a-shared-ow.md +6 -0
  164. package/corpus/templates/design/changelog/2026-10-21-a-getting-started-checklist-at-the-bottom-of-the-sidebar-tra.md +6 -0
  165. package/corpus/templates/design/changelog/2026-10-21-templates-now-open-a-read-only-preview-from-the-card-and-the.md +6 -0
  166. package/corpus/templates/design/changelog/2026-10-22-collapsed-shader-layers-no-longer-interrupt-other-shaders-on.md +6 -0
  167. package/corpus/templates/design/changelog/2026-10-22-shader-fills-keep-rendering-when-other-screen-content-cannot.md +6 -0
  168. package/corpus/templates/design/scripts/design-export-validation/export-baseline.json +24 -0
  169. package/corpus/templates/design/server/lib/getting-started.ts +267 -0
  170. package/corpus/templates/design/server/plugins/getting-started.ts +5 -0
  171. package/corpus/templates/design/shared/design-template-presets/analytics-dashboard.ts +127 -0
  172. package/corpus/templates/design/shared/design-template-presets/design-studio.ts +98 -0
  173. package/corpus/templates/design/shared/design-template-presets/messaging-app.ts +101 -0
  174. package/corpus/templates/design/shared/design-template-presets/project-tasks.ts +120 -0
  175. package/corpus/templates/design/shared/design-template-presets.ts +8 -0
  176. package/corpus/templates/dispatch/CHANGELOG.md +4 -0
  177. package/corpus/templates/dispatch/app/i18n-data.ts +75 -0
  178. package/corpus/templates/dispatch/changelog/2026-10-21-owned-and-shared-chats-appear-in-separate-history-sections.md +6 -0
  179. package/corpus/templates/factory/app/components/layout/Sidebar.tsx +34 -100
  180. package/corpus/templates/forms/app/pages/FormsListPage.tsx +1 -1
  181. package/corpus/templates/forms/server/plugins/agent-chat.ts +1 -1
  182. package/corpus/templates/plan/CHANGELOG.md +6 -0
  183. package/corpus/templates/plan/app/components/layout/Sidebar.tsx +34 -109
  184. package/corpus/templates/plan/changelog/2026-10-16-owned-and-shared-chats-appear-in-separate-history-sections.md +6 -0
  185. package/corpus/templates/slides/CHANGELOG.md +7 -0
  186. package/corpus/templates/slides/actions/delete-deck.ts +7 -0
  187. package/corpus/templates/slides/app/components/deck/DeckCard.tsx +1 -1
  188. package/corpus/templates/slides/app/components/design-system/DesignSystemCard.tsx +1 -1
  189. package/corpus/templates/slides/app/pages/Index.tsx +7 -2
  190. package/corpus/templates/slides/changelog/2026-10-16-deck-lists-use-the-shared-ownership-filter-and-visibility-ba.md +6 -0
  191. package/corpus/templates/slides/changelog/2026-10-21-connected-deck-deletion-requires-approval.md +6 -0
  192. package/corpus/templates/slides/scripts/edit-fidelity/client-action.ts +61 -0
  193. package/corpus/templates/slides/scripts/edit-fidelity/run.ts +10 -0
  194. package/package.json +1 -1
  195. package/corpus/templates/chat/app/lib/sidebar-thread-state.ts +0 -29
  196. package/corpus/templates/slides/app/components/deck/DeckFilterMenu.tsx +0 -83
@@ -5,6 +5,7 @@ import { defineAction, type ActionRunContext } from "@agent-native/core/action";
5
5
  import { writeAppState } from "@agent-native/core/application-state";
6
6
  import { withCommittedActionAudit } from "@agent-native/core/audit";
7
7
  import { agentTouchDocument } from "@agent-native/core/collab";
8
+ import { getDbExec } from "@agent-native/core/db";
8
9
  import {
9
10
  iconValueSchema,
10
11
  parseIconValue,
@@ -49,12 +50,20 @@ import {
49
50
  parseDocumentFavorite,
50
51
  parseDocumentHideFromSearch,
51
52
  } from "../server/lib/documents.js";
53
+ import {
54
+ liveBodyWriterGateForRow,
55
+ liveBodyWriterPageColumns,
56
+ } from "../server/lib/live-body-writer.js";
52
57
  import {
53
58
  syncPrivateCalloutReferences,
54
59
  syncPrivateIconReference,
55
60
  verifyPrivateIconAssignment,
56
61
  } from "../server/lib/private-icon-references.js";
57
- import type { DocumentUpdateResponse } from "../shared/api.js";
62
+ import { liveCopyHoldsSave } from "../server/live-copy/holds.js";
63
+ import type {
64
+ DocumentUpdateResponse,
65
+ LiveCopyBodySave,
66
+ } from "../shared/api.js";
58
67
  import { applyContentPersonalNavigationPatch } from "../shared/content-personal-navigation-patch.js";
59
68
  import { mergeDocumentBodyIntents } from "../shared/document-intent-merge.js";
60
69
  import { inspectNfmFidelity } from "../shared/nfm.js";
@@ -206,6 +215,13 @@ function documentUpdateResponse(
206
215
  source: serializeDocumentSource(doc),
207
216
  softDeletedDatabaseIds,
208
217
  ...(browserSaveAttempt ? { browserSaveAttempt } : {}),
218
+ // Only a save the live copy holds keeps a receipt.
219
+ ...(browserSaveAttempt?.bodySave
220
+ ? {
221
+ bodySave: browserSaveAttempt.bodySave,
222
+ bodyIntentOutcome: { status: "applied" as const },
223
+ }
224
+ : {}),
209
225
  };
210
226
  }
211
227
 
@@ -974,7 +990,7 @@ const updateDocumentAction = defineAction({
974
990
  args.authoredCandidateContent === undefined
975
991
  ? undefined
976
992
  : normalizeAuthoredContent(args.authoredCandidateContent);
977
- let browserSaveContentRejected = false;
993
+ let staleBodySave: "stale_builder_image" | "stale_empty_body" | undefined;
978
994
  if (content !== undefined && !args.preserveLeadingTitleHeading) {
979
995
  if (
980
996
  content.includes('componentName="Image"') &&
@@ -1012,12 +1028,11 @@ const updateDocumentAction = defineAction({
1012
1028
  sourceContent,
1013
1029
  })
1014
1030
  ) {
1015
- browserSaveContentRejected = true;
1016
- measurement.reason_code = "stale_builder_image";
1017
- content = existing.content;
1031
+ staleBodySave = "stale_builder_image";
1018
1032
  }
1019
1033
  }
1020
1034
  if (
1035
+ !staleBodySave &&
1021
1036
  shouldRejectStaleEmptyBodySave({
1022
1037
  incomingContent: content,
1023
1038
  currentContent: existing.content,
@@ -1026,13 +1041,26 @@ const updateDocumentAction = defineAction({
1026
1041
  loadedContentWasEmpty: args.loadedContentWasEmpty,
1027
1042
  })
1028
1043
  ) {
1029
- browserSaveContentRejected = true;
1030
- measurement.stale_base = "true";
1031
- measurement.reason_code = "stale_empty_body";
1032
- content = existing.content;
1044
+ staleBodySave = "stale_empty_body";
1033
1045
  }
1034
1046
  }
1035
- if (browserSaveContentRejected && args.browserSaveAttemptId) {
1047
+ // These guard a body write, and a switched page's body may not be
1048
+ // written here at all: the gate, read under the page lock, decides.
1049
+ const staleBodySaveDeferred =
1050
+ ctx?.caller === "frontend" && !!existing.liveBodySwitchedAt;
1051
+ /** Keeps the stored body; true when the save must answer a conflict. */
1052
+ const refuseStaleBodySave = (storedContent: string) => {
1053
+ measurement.reason_code = staleBodySave;
1054
+ if (staleBodySave === "stale_empty_body")
1055
+ measurement.stale_base = "true";
1056
+ content = storedContent;
1057
+ return !!args.browserSaveAttemptId;
1058
+ };
1059
+ if (
1060
+ staleBodySave &&
1061
+ !staleBodySaveDeferred &&
1062
+ refuseStaleBodySave(existing.content)
1063
+ ) {
1036
1064
  measurement.outcome = "conflict";
1037
1065
  return respond(
1038
1066
  documentConflictResponse(existing, access.role, currentFavorite),
@@ -1072,6 +1100,11 @@ const updateDocumentAction = defineAction({
1072
1100
  let browserSaveConfirmation: BrowserSaveAttemptConfirmation | undefined;
1073
1101
  let bodyIntentOutcome: BrowserDocumentUpdateResponse["bodyIntentOutcome"];
1074
1102
  let discardedEditorGeneration: number | undefined;
1103
+ let liveCopyBodySave: LiveCopyBodySave | undefined;
1104
+ // Only a save the live copy already holds settles its draft or keeps a
1105
+ // receipt. A tab whose updates never reached the live copy has nothing
1106
+ // else on the server holding its text.
1107
+ let settlesLiveCopyBody = false;
1075
1108
  let preservationRequired:
1076
1109
  | {
1077
1110
  reason: "structure" | "provenance";
@@ -1226,10 +1259,76 @@ const updateDocumentAction = defineAction({
1226
1259
  contentCasConflict = true;
1227
1260
  return;
1228
1261
  }
1262
+ if (ctx?.caller === "frontend" && content !== undefined) {
1263
+ const [page] = await tx
1264
+ .select(liveBodyWriterPageColumns)
1265
+ .from(schema.documents)
1266
+ .where(eq(schema.documents.id, id))
1267
+ .limit(1);
1268
+ const gate = await liveBodyWriterGateForRow(page, {
1269
+ transaction: getDbExec(),
1270
+ });
1271
+ if (gate.writePath === "live_copy") {
1272
+ // The editor's updates reach a live copy over collab, so the
1273
+ // body part of its save is not written here. Comparing it with
1274
+ // the stored body would read every peer's live-copy commit as
1275
+ // an unmerged edit and divert this save to History.
1276
+ const saveBodies = new Set(
1277
+ [
1278
+ args.content,
1279
+ args.authoredCandidateContent,
1280
+ args.editorSnapshotContent,
1281
+ ].filter((body): body is string => body !== undefined),
1282
+ );
1283
+ const matchesLiveCopy = [...saveBodies].every(
1284
+ (body) => body === historyBefore.content,
1285
+ );
1286
+ liveCopyBodySave = {
1287
+ status: "live_copy",
1288
+ matchesLiveCopy,
1289
+ bodyRevision: historyBefore.bodyRevision,
1290
+ collabVersion: page.liveCopyVersion as number,
1291
+ };
1292
+ // A tab's body trails peers' commits it hasn't received, so
1293
+ // most saves from two open tabs match no stored body. Old
1294
+ // editors read an unsettled save as unsaved and send its text
1295
+ // to History when the tab reopens.
1296
+ // A receipt acknowledges the submitted body, authored candidate
1297
+ // and recovery snapshot. SQL strips title headings they retain.
1298
+ settlesLiveCopyBody = true;
1299
+ for (const body of saveBodies) {
1300
+ if (body === historyBefore.content) continue;
1301
+ const hold = authoredBase
1302
+ ? await liveCopyHoldsSave({
1303
+ base: args.authoredBaseContent as string,
1304
+ stored: historyBefore.content,
1305
+ candidate: body,
1306
+ })
1307
+ : undefined;
1308
+ if (hold) measurement.reason_code = `live_copy_save_${hold}`;
1309
+ if (hold !== "held") {
1310
+ settlesLiveCopyBody = false;
1311
+ break;
1312
+ }
1313
+ }
1314
+ if (settlesLiveCopyBody) {
1315
+ bodyIntentOutcome = { status: "applied" };
1316
+ }
1317
+ content = undefined;
1318
+ } else if (
1319
+ staleBodySave &&
1320
+ staleBodySaveDeferred &&
1321
+ refuseStaleBodySave(historyBefore.content)
1322
+ ) {
1323
+ contentCasConflict = true;
1324
+ return;
1325
+ }
1326
+ }
1229
1327
  const confirmBrowserSave = async (
1230
1328
  snapshot: { title: string; content: string },
1231
1329
  now: string,
1232
1330
  ) => {
1331
+ if (liveCopyBodySave && !settlesLiveCopyBody) return;
1233
1332
  if (
1234
1333
  settlesPreviewDraft &&
1235
1334
  args.editorSnapshotTitle !== undefined &&
@@ -1261,17 +1360,23 @@ const updateDocumentAction = defineAction({
1261
1360
  updatedAt: schema.documents.updatedAt,
1262
1361
  })
1263
1362
  .from(schema.documents)
1264
- .where(eq(schema.documents.id, id))
1363
+ .where(
1364
+ and(
1365
+ eq(schema.documents.id, id),
1366
+ eq(schema.documents.ownerEmail, ownerEmail),
1367
+ ),
1368
+ )
1265
1369
  .limit(1);
1266
1370
  const confirmation: BrowserSaveAttemptConfirmation = {
1267
1371
  attemptId: args.browserSaveAttemptId,
1268
- result: "applied",
1372
+ result: liveCopyBodySave ? "live_copy" : "applied",
1269
1373
  revision: documentRevisionToken(
1270
1374
  confirmed.bodyRevision,
1271
1375
  confirmed.content,
1272
1376
  ),
1273
1377
  updatedAt: confirmed.updatedAt,
1274
1378
  softDeletedDatabaseIds,
1379
+ ...(liveCopyBodySave ? { bodySave: liveCopyBodySave } : {}),
1275
1380
  };
1276
1381
  await tx.insert(schema.documentBrowserSaveAttempts).values({
1277
1382
  id: randomUUID(),
@@ -1333,7 +1438,11 @@ const updateDocumentAction = defineAction({
1333
1438
  });
1334
1439
  }
1335
1440
  };
1336
- if (authoredBase && authoredCandidateContent !== undefined) {
1441
+ if (
1442
+ authoredBase &&
1443
+ authoredCandidateContent !== undefined &&
1444
+ !liveCopyBodySave
1445
+ ) {
1337
1446
  const prior = await findDocumentBodyIntent({
1338
1447
  db: tx as ReturnType<typeof getDb>,
1339
1448
  ownerEmail,
@@ -1382,7 +1491,11 @@ const updateDocumentAction = defineAction({
1382
1491
  { status: "resolved" }
1383
1492
  >
1384
1493
  | undefined;
1385
- if (authoredBase && authoredCandidateContent !== undefined) {
1494
+ if (
1495
+ authoredBase &&
1496
+ authoredCandidateContent !== undefined &&
1497
+ !liveCopyBodySave
1498
+ ) {
1386
1499
  const priorIntents = await readDocumentBodyIntents({
1387
1500
  db: tx as ReturnType<typeof getDb>,
1388
1501
  ownerEmail,
@@ -1497,7 +1610,10 @@ const updateDocumentAction = defineAction({
1497
1610
  if (
1498
1611
  intentMerge ||
1499
1612
  (useBodyRevisionCas && lockedContentChanged) ||
1500
- useDocumentCas
1613
+ // Peers' live-copy commits move updatedAt, so on a live-copy
1614
+ // save the snapshot guards only the fields it still writes.
1615
+ (useDocumentCas &&
1616
+ (!liveCopyBodySave || lockedDocumentFieldsChanged))
1501
1617
  ) {
1502
1618
  const rows = await tx
1503
1619
  .update(schema.documents)
@@ -1596,11 +1712,13 @@ const updateDocumentAction = defineAction({
1596
1712
  );
1597
1713
  }
1598
1714
  committedContentChanged = lockedContentChanged;
1599
- measurement.outcome = lockedDocumentFieldsChanged
1600
- ? intentMerge && bodyBaseStale && lockedContentChanged
1601
- ? "merged"
1602
- : "written"
1603
- : "unchanged";
1715
+ measurement.outcome = liveCopyBodySave
1716
+ ? "live_copy_body"
1717
+ : lockedDocumentFieldsChanged
1718
+ ? intentMerge && bodyBaseStale && lockedContentChanged
1719
+ ? "merged"
1720
+ : "written"
1721
+ : "unchanged";
1604
1722
  committedContentBefore = historyBefore.content;
1605
1723
 
1606
1724
  if (lockedTitleChanged && args.title !== undefined) {
@@ -1924,6 +2042,7 @@ const updateDocumentAction = defineAction({
1924
2042
  browserSaveConfirmation,
1925
2043
  ),
1926
2044
  ...(bodyIntentOutcome ? { bodyIntentOutcome } : {}),
2045
+ ...(liveCopyBodySave ? { bodySave: liveCopyBodySave } : {}),
1927
2046
  ...(creativeContext
1928
2047
  ? {
1929
2048
  contextMode: creativeContext.contextMode,
@@ -1,6 +1,6 @@
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
- import { and, eq } from "drizzle-orm";
3
+ import { and, eq, isNull } from "drizzle-orm";
4
4
  import { z } from "zod";
5
5
 
6
6
  import { getDb, schema } from "../server/db/index.js";
@@ -8,6 +8,14 @@ import type {
8
8
  ContentDatabaseResponse,
9
9
  ValidateBuilderSourceExecutionRequest,
10
10
  } from "../shared/api.js";
11
+ import {
12
+ builderExecutionPayloadReference,
13
+ cleanupBuilderPrivatePayload,
14
+ enqueueBuilderPrivatePayloadCleanup,
15
+ readBuilderExecutionPayload,
16
+ storeBuilderExecutionPayload,
17
+ } from "./_builder-cms-blob-custody.js";
18
+ import { isBuilderExecutionClaimStale } from "./_builder-cms-execution-lease.js";
11
19
  import {
12
20
  buildBuilderCmsExecutionPlan,
13
21
  builderCmsExecutionIdempotencyKey,
@@ -19,17 +27,6 @@ import {
19
27
  } from "./_database-source-utils.js";
20
28
  import { getContentDatabaseResponse } from "./_database-utils.js";
21
29
 
22
- function parsePayload(value: string) {
23
- try {
24
- const parsed = JSON.parse(value) as unknown;
25
- return parsed && typeof parsed === "object" && !Array.isArray(parsed)
26
- ? (parsed as Record<string, unknown>)
27
- : {};
28
- } catch {
29
- return {};
30
- }
31
- }
32
-
33
30
  export default defineAction({
34
31
  description:
35
32
  "Validate a prepared Builder CMS execution gate as a local dry run. This rebuilds and compares the write plan, but never calls Builder APIs.",
@@ -117,10 +114,45 @@ export default defineAction({
117
114
  "Prepare the Builder execution gate before validating it.",
118
115
  );
119
116
  }
120
-
121
117
  const now = new Date().toISOString();
118
+ if (execution.state === "succeeded") {
119
+ fail("Builder execution already succeeded and cannot be revalidated.", {
120
+ errorCode: "builder_execution_already_succeeded",
121
+ statusCode: 409,
122
+ });
123
+ }
124
+ if (
125
+ execution.state === "response_received" ||
126
+ execution.state === "reconciliation_required"
127
+ ) {
128
+ fail("Builder execution requires reconciliation before validation.", {
129
+ errorCode: "builder_execution_reconciliation_required",
130
+ statusCode: 409,
131
+ });
132
+ }
133
+ if (
134
+ execution.state === "running" &&
135
+ !isBuilderExecutionClaimStale(execution.updatedAt, now)
136
+ ) {
137
+ fail("Builder execution is already running.", {
138
+ errorCode: "builder_execution_running",
139
+ statusCode: 409,
140
+ });
141
+ }
142
+
143
+ const binding = {
144
+ ownerEmail: execution.ownerEmail,
145
+ sourceId: execution.sourceId,
146
+ changeSetId: execution.changeSetId,
147
+ executionId: execution.id,
148
+ idempotencyKey: execution.idempotencyKey,
149
+ };
150
+ const storedPayload = await readBuilderExecutionPayload({
151
+ payloadJson: execution.payloadJson,
152
+ binding,
153
+ });
122
154
  const payload = validateBuilderCmsExecutionDryRun({
123
- storedPayload: parsePayload(execution.payloadJson),
155
+ storedPayload,
124
156
  plan,
125
157
  now,
126
158
  });
@@ -136,17 +168,94 @@ export default defineAction({
136
168
  ? `${plan.summary} Dry run validated blockers locally.`
137
169
  : `${plan.summary} Dry run found a stale execution gate.${stale}`;
138
170
 
139
- await db
140
- .update(schema.contentDatabaseSourceExecutions)
141
- .set({
142
- state: dryRun?.status === "stale" ? "blocked" : plan.state,
143
- summary,
144
- payloadJson: JSON.stringify(payload),
145
- lastError:
146
- dryRun?.status === "stale" ? dryRun.mismatches[0] : plan.lastError,
147
- updatedAt: now,
148
- })
149
- .where(eq(schema.contentDatabaseSourceExecutions.id, execution.id));
171
+ const payloadJson = await storeBuilderExecutionPayload({
172
+ payload,
173
+ binding,
174
+ });
175
+ const nextReference = builderExecutionPayloadReference(payloadJson);
176
+ const previousReference = builderExecutionPayloadReference(
177
+ execution.payloadJson,
178
+ );
179
+ try {
180
+ const updatedRows = await db.transaction(async (tx) => {
181
+ const updated = await tx
182
+ .update(schema.contentDatabaseSourceExecutions)
183
+ .set({
184
+ state: dryRun?.status === "stale" ? "blocked" : plan.state,
185
+ summary,
186
+ payloadJson,
187
+ attemptToken: null,
188
+ lastError:
189
+ dryRun?.status === "stale"
190
+ ? dryRun.mismatches[0]
191
+ : plan.lastError,
192
+ updatedAt: now,
193
+ })
194
+ .where(
195
+ and(
196
+ eq(schema.contentDatabaseSourceExecutions.id, execution.id),
197
+ eq(
198
+ schema.contentDatabaseSourceExecutions.payloadJson,
199
+ execution.payloadJson,
200
+ ),
201
+ eq(schema.contentDatabaseSourceExecutions.state, execution.state),
202
+ execution.attemptToken
203
+ ? eq(
204
+ schema.contentDatabaseSourceExecutions.attemptToken,
205
+ execution.attemptToken,
206
+ )
207
+ : isNull(schema.contentDatabaseSourceExecutions.attemptToken),
208
+ ),
209
+ )
210
+ .returning({ id: schema.contentDatabaseSourceExecutions.id });
211
+ if (
212
+ updated[0] &&
213
+ previousReference &&
214
+ previousReference !== nextReference
215
+ ) {
216
+ await enqueueBuilderPrivatePayloadCleanup(
217
+ tx as unknown as ReturnType<typeof getDb>,
218
+ previousReference,
219
+ );
220
+ }
221
+ return updated;
222
+ });
223
+ const [updated] = updatedRows;
224
+ if (!updated)
225
+ fail("Builder execution changed during validation.", {
226
+ errorCode: "builder_execution_changed",
227
+ statusCode: 409,
228
+ });
229
+ } catch (error) {
230
+ let currentPayloadJson: string | null | undefined;
231
+ try {
232
+ const [current] = await db
233
+ .select({
234
+ payloadJson: schema.contentDatabaseSourceExecutions.payloadJson,
235
+ })
236
+ .from(schema.contentDatabaseSourceExecutions)
237
+ .where(eq(schema.contentDatabaseSourceExecutions.id, execution.id))
238
+ .limit(1);
239
+ currentPayloadJson = current?.payloadJson;
240
+ } catch {
241
+ // Inconclusive readback retains both refs.
242
+ }
243
+ if (currentPayloadJson !== payloadJson) {
244
+ if (currentPayloadJson !== undefined && nextReference) {
245
+ await cleanupBuilderPrivatePayload(
246
+ nextReference,
247
+ "uncommitted validated execution",
248
+ );
249
+ }
250
+ throw error;
251
+ }
252
+ }
253
+ if (previousReference && previousReference !== nextReference) {
254
+ await cleanupBuilderPrivatePayload(
255
+ previousReference,
256
+ "superseded validated execution",
257
+ );
258
+ }
150
259
 
151
260
  await db
152
261
  .update(schema.contentDatabaseSources)