@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
@@ -1,6 +1,10 @@
1
- import { defineAction } from "@agent-native/core/action";
1
+ import {
2
+ defineAction,
3
+ fail,
4
+ isActionContractError,
5
+ } from "@agent-native/core/action";
2
6
  import { assertAccess } from "@agent-native/core/sharing";
3
- import { and, eq, lt, notInArray, or } from "drizzle-orm";
7
+ import { and, eq, exists, isNull, lt, notInArray, or } from "drizzle-orm";
4
8
  import { z } from "zod";
5
9
 
6
10
  import { getDb, schema } from "../server/db/index.js";
@@ -14,6 +18,17 @@ import {
14
18
  type DocumentPropertyValue,
15
19
  type ExecuteBuilderSourceExecutionRequest,
16
20
  } from "../shared/api.js";
21
+ import {
22
+ builderExecutionPayloadReference,
23
+ cleanupBuilderPrivatePayload,
24
+ enqueueBuilderPrivatePayloadCleanup,
25
+ readBuilderExecutionPayload,
26
+ storeBuilderExecutionPayload,
27
+ } from "./_builder-cms-blob-custody.js";
28
+ import {
29
+ builderExecutionStaleBefore,
30
+ isBuilderExecutionClaimStale,
31
+ } from "./_builder-cms-execution-lease.js";
17
32
  import {
18
33
  lookupBuilderCmsSafeModelIntent,
19
34
  type BuilderCmsIntentMatch,
@@ -44,6 +59,7 @@ import {
44
59
  executeBuilderCmsWrite,
45
60
  } from "./_builder-cms-write-client.js";
46
61
  import { createBuilderSourceTiming } from "./_builder-source-timings.js";
62
+ import { lockContentDatabaseMutation } from "./_content-database-mutation-lock.js";
47
63
  import { lockDatabaseMemberships } from "./_database-membership-lock.js";
48
64
  import {
49
65
  getContentDatabaseSourceSnapshotForWrite,
@@ -60,6 +76,7 @@ export interface BuilderSourceExecutionRecord {
60
76
  state: ContentDatabaseSourceExecutionState | (string & {});
61
77
  idempotencyKey: string;
62
78
  payloadJson: string;
79
+ lastError?: string | null;
63
80
  attemptToken?: string | null;
64
81
  updatedAt: string;
65
82
  }
@@ -84,6 +101,10 @@ export interface ExecuteBuilderSourceExecutionDeps {
84
101
  }) => Promise<BuilderSourceExecutionRecord | null>;
85
102
  updateExecutionState: (args: {
86
103
  executionId: string;
104
+ expected: Pick<
105
+ BuilderSourceExecutionRecord,
106
+ "state" | "updatedAt" | "attemptToken"
107
+ >;
87
108
  state: ContentDatabaseSourceExecutionState;
88
109
  summary: string;
89
110
  payload: unknown;
@@ -92,18 +113,35 @@ export interface ExecuteBuilderSourceExecutionDeps {
92
113
  }) => Promise<void>;
93
114
  claimExecution: (args: {
94
115
  executionId: string;
116
+ changeSetId: string;
117
+ /** The row the gate validated; any later write voids the claim. */
118
+ expected: Pick<
119
+ BuilderSourceExecutionRecord,
120
+ "state" | "updatedAt" | "attemptToken"
121
+ >;
95
122
  summary: string;
96
123
  payload: unknown;
97
124
  now: string;
98
125
  staleBefore: string;
99
126
  attemptToken: string;
100
127
  }) => Promise<boolean>;
128
+ renewExecutionClaim: (args: {
129
+ executionId: string;
130
+ attemptToken: string;
131
+ now: string;
132
+ }) => Promise<boolean>;
101
133
  checkpointResponse?: (args: {
102
134
  executionId: string;
103
135
  attemptToken: string;
104
136
  payload: unknown;
105
137
  now: string;
106
138
  }) => Promise<boolean>;
139
+ markResponseCheckpointFailed: (args: {
140
+ executionId: string;
141
+ attemptToken: string;
142
+ lastError: string;
143
+ now: string;
144
+ }) => Promise<boolean>;
107
145
  markExecutionSucceeded: (args: {
108
146
  executionId: string;
109
147
  changeSetId: string;
@@ -121,12 +159,29 @@ export interface ExecuteBuilderSourceExecutionDeps {
121
159
  attemptToken?: string;
122
160
  state?: "failed" | "reconciliation_required";
123
161
  }) => Promise<void>;
162
+ releaseExecutionClaim: (args: {
163
+ executionId: string;
164
+ attemptToken: string;
165
+ restore: Pick<
166
+ BuilderSourceExecutionRecord,
167
+ "state" | "attemptToken" | "updatedAt"
168
+ >;
169
+ summary: string;
170
+ payload: unknown;
171
+ lastError: string;
172
+ now: string;
173
+ }) => Promise<void>;
124
174
  executeWrite: (args: {
125
175
  request: BuilderCmsExecutionPayload["request"];
176
+ expectedSourceSpace?: string | null;
177
+ expectedSourceConnectionId?: string | null;
178
+ requireSourceBinding?: boolean;
126
179
  }) => ReturnType<typeof executeBuilderCmsWrite>;
127
180
  readLiveEntry: (args: {
128
181
  model: string;
129
182
  entryId: string;
183
+ expectedSourceSpace?: string | null;
184
+ expectedSourceConnectionId?: string | null;
130
185
  }) => Promise<BuilderCmsEntryLiveState>;
131
186
  reconcileWrite: (args: {
132
187
  database: DatabaseRecord;
@@ -141,6 +196,8 @@ export interface ExecuteBuilderSourceExecutionDeps {
141
196
  marker?: string;
142
197
  exactTitle?: string;
143
198
  intendedFields?: Record<string, unknown>;
199
+ expectedSourceSpace?: string | null;
200
+ expectedSourceConnectionId?: string | null;
144
201
  }) => Promise<{
145
202
  count: number;
146
203
  matchingIntentCount?: number;
@@ -148,7 +205,9 @@ export interface ExecuteBuilderSourceExecutionDeps {
148
205
  }>;
149
206
  }
150
207
 
151
- const RUNNING_EXECUTION_TIMEOUT_MS = 10 * 60 * 1000;
208
+ const BUILDER_LIVE_PREFLIGHT_TIMEOUT_MS = 60 * 1000;
209
+ const BUILDER_RESPONSE_CHECKPOINT_FAILURE =
210
+ "Builder accepted the write, but its response could not be saved for reconciliation. Do not retry this execution; inspect the live Builder entry before preparing another write.";
152
211
 
153
212
  function parsePayload(value: string) {
154
213
  try {
@@ -203,14 +262,11 @@ function executablePushMode(
203
262
  }
204
263
 
205
264
  function staleRunningCutoff(now: string) {
206
- const timestamp = Date.parse(now);
207
- return new Date(
208
- (Number.isFinite(timestamp) ? timestamp : Date.now()) -
209
- RUNNING_EXECUTION_TIMEOUT_MS,
210
- ).toISOString();
265
+ return builderExecutionStaleBefore(now);
211
266
  }
212
267
 
213
268
  export function builderExecutionAffectedRows(result: unknown) {
269
+ if (!result || typeof result !== "object") return 0;
214
270
  return (
215
271
  (
216
272
  result as {
@@ -233,14 +289,18 @@ function isReclaimableRunningExecution(
233
289
  execution: BuilderSourceExecutionRecord,
234
290
  now: string,
235
291
  ) {
292
+ if (
293
+ execution.state === "reconciliation_required" &&
294
+ execution.lastError === BUILDER_RESPONSE_CHECKPOINT_FAILURE
295
+ ) {
296
+ return false;
297
+ }
236
298
  if (
237
299
  execution.state !== "running" &&
238
300
  execution.state !== "reconciliation_required"
239
301
  )
240
302
  return true;
241
- const updatedAt = Date.parse(execution.updatedAt);
242
- if (!Number.isFinite(updatedAt)) return false;
243
- return updatedAt < Date.parse(staleRunningCutoff(now));
303
+ return isBuilderExecutionClaimStale(execution.updatedAt, now);
244
304
  }
245
305
 
246
306
  function executionResponsePayload(args: {
@@ -478,6 +538,14 @@ function livePreflightBlockMessage(args: {
478
538
  if (!args.liveState.exists) {
479
539
  return "Builder entry no longer exists; refresh the source.";
480
540
  }
541
+ // Two missing timestamps compare equal, so an absent value must block
542
+ // rather than read as "unchanged".
543
+ if (!args.baselineLastUpdated?.trim()) {
544
+ return "This change has no reviewed Builder update time; refresh and re-review.";
545
+ }
546
+ if (toEpochMs(args.liveState.lastUpdated) === null) {
547
+ return "Builder did not report when this entry last changed; refresh and re-review.";
548
+ }
481
549
  if (
482
550
  liveTimestampsDiffer({
483
551
  liveLastUpdated: args.liveState.lastUpdated,
@@ -647,6 +715,105 @@ export function realExecutionDeps(
647
715
  sourceId?: string,
648
716
  changeSetId?: string,
649
717
  ): ExecuteBuilderSourceExecutionDeps {
718
+ const storedPayloadJson = async (executionId: string, payload: unknown) => {
719
+ const [execution] = await getDb()
720
+ .select()
721
+ .from(schema.contentDatabaseSourceExecutions)
722
+ .where(eq(schema.contentDatabaseSourceExecutions.id, executionId))
723
+ .limit(1);
724
+ if (!execution)
725
+ fail("Builder execution disappeared.", {
726
+ errorCode: "builder_execution_changed",
727
+ statusCode: 409,
728
+ });
729
+ const payloadJson = await storeBuilderExecutionPayload({
730
+ payload,
731
+ binding: {
732
+ ownerEmail: execution.ownerEmail,
733
+ sourceId: execution.sourceId,
734
+ changeSetId: execution.changeSetId,
735
+ executionId: execution.id,
736
+ idempotencyKey: execution.idempotencyKey,
737
+ },
738
+ });
739
+ return {
740
+ payloadJson,
741
+ previousPayloadJson: execution.payloadJson,
742
+ previousState: execution.state,
743
+ previousAttemptToken: execution.attemptToken,
744
+ previousReference: builderExecutionPayloadReference(
745
+ execution.payloadJson,
746
+ ),
747
+ nextReference: builderExecutionPayloadReference(payloadJson),
748
+ };
749
+ };
750
+ const previousAttemptFilter = (prepared: {
751
+ previousAttemptToken: string | null;
752
+ }) =>
753
+ prepared.previousAttemptToken
754
+ ? eq(
755
+ schema.contentDatabaseSourceExecutions.attemptToken,
756
+ prepared.previousAttemptToken,
757
+ )
758
+ : isNull(schema.contentDatabaseSourceExecutions.attemptToken);
759
+ const discardUncommittedPayload = async (prepared: {
760
+ nextReference: string | null;
761
+ }) => {
762
+ if (prepared.nextReference) {
763
+ await cleanupBuilderPrivatePayload(
764
+ prepared.nextReference,
765
+ "uncommitted execution payload",
766
+ );
767
+ }
768
+ };
769
+ const updateStoredPayload = async (
770
+ prepared: Awaited<ReturnType<typeof storedPayloadJson>>,
771
+ update: (tx: ReturnType<typeof getDb>) => PromiseLike<unknown>,
772
+ ) =>
773
+ getDb().transaction(async (tx) => {
774
+ const transactionDb = tx as unknown as ReturnType<typeof getDb>;
775
+ const result = await update(transactionDb);
776
+ if (
777
+ builderExecutionAffectedRows(result) > 0 &&
778
+ prepared.previousReference &&
779
+ prepared.previousReference !== prepared.nextReference
780
+ ) {
781
+ await enqueueBuilderPrivatePayloadCleanup(
782
+ transactionDb,
783
+ prepared.previousReference,
784
+ );
785
+ }
786
+ return result;
787
+ });
788
+ const resolveAmbiguousPayloadWrite = async (
789
+ executionId: string,
790
+ prepared: {
791
+ payloadJson: string;
792
+ previousReference: string | null;
793
+ nextReference: string | null;
794
+ },
795
+ ) => {
796
+ try {
797
+ const [current] = await getDb()
798
+ .select({
799
+ payloadJson: schema.contentDatabaseSourceExecutions.payloadJson,
800
+ })
801
+ .from(schema.contentDatabaseSourceExecutions)
802
+ .where(eq(schema.contentDatabaseSourceExecutions.id, executionId))
803
+ .limit(1);
804
+ if (current?.payloadJson === prepared.payloadJson) {
805
+ return "committed" as const;
806
+ }
807
+ if (current) {
808
+ await discardUncommittedPayload(prepared);
809
+ return "orphan" as const;
810
+ }
811
+ } catch {
812
+ // coercion-ok: an unavailable readback is explicitly the unknown outcome.
813
+ // Inconclusive readback retains both refs for a later SQL-truth reload.
814
+ }
815
+ return "unknown" as const;
816
+ };
650
817
  return {
651
818
  now: () => new Date().toISOString(),
652
819
  resolveDatabase: (args) => resolveDatabaseForSourceMutation(args),
@@ -724,64 +891,263 @@ export function realExecutionDeps(
724
891
  ) {
725
892
  return null;
726
893
  }
727
- return execution ?? null;
894
+ if (!execution) return null;
895
+ const payload = await readBuilderExecutionPayload({
896
+ payloadJson: execution.payloadJson,
897
+ binding: {
898
+ ownerEmail: execution.ownerEmail,
899
+ sourceId: execution.sourceId,
900
+ changeSetId: execution.changeSetId,
901
+ executionId: execution.id,
902
+ idempotencyKey: execution.idempotencyKey,
903
+ },
904
+ });
905
+ return { ...execution, payloadJson: JSON.stringify(payload) };
728
906
  },
729
907
  updateExecutionState: async (args) => {
730
- await getDb()
731
- .update(schema.contentDatabaseSourceExecutions)
732
- .set({
733
- state: args.state,
734
- summary: args.summary,
735
- payloadJson: JSON.stringify(args.payload),
736
- lastError: args.lastError,
737
- updatedAt: args.now,
738
- })
739
- .where(eq(schema.contentDatabaseSourceExecutions.id, args.executionId));
908
+ const prepared = await storedPayloadJson(args.executionId, args.payload);
909
+ let result;
910
+ try {
911
+ result = await updateStoredPayload(prepared, (tx) =>
912
+ tx
913
+ .update(schema.contentDatabaseSourceExecutions)
914
+ .set({
915
+ state: args.state,
916
+ summary: args.summary,
917
+ payloadJson: prepared.payloadJson,
918
+ lastError: args.lastError,
919
+ updatedAt: args.now,
920
+ })
921
+ .where(
922
+ and(
923
+ eq(schema.contentDatabaseSourceExecutions.id, args.executionId),
924
+ eq(
925
+ schema.contentDatabaseSourceExecutions.payloadJson,
926
+ prepared.previousPayloadJson,
927
+ ),
928
+ eq(
929
+ schema.contentDatabaseSourceExecutions.state,
930
+ prepared.previousState,
931
+ ),
932
+ previousAttemptFilter(prepared),
933
+ eq(
934
+ schema.contentDatabaseSourceExecutions.state,
935
+ args.expected.state,
936
+ ),
937
+ eq(
938
+ schema.contentDatabaseSourceExecutions.updatedAt,
939
+ args.expected.updatedAt,
940
+ ),
941
+ previousAttemptFilter({
942
+ previousAttemptToken: args.expected.attemptToken ?? null,
943
+ }),
944
+ ),
945
+ ),
946
+ );
947
+ } catch (error) {
948
+ if (
949
+ (await resolveAmbiguousPayloadWrite(args.executionId, prepared)) ===
950
+ "committed"
951
+ ) {
952
+ return;
953
+ }
954
+ throw error;
955
+ }
956
+ if (builderExecutionAffectedRows(result) === 0) {
957
+ await discardUncommittedPayload(prepared);
958
+ fail("Builder execution changed before state update.", {
959
+ errorCode: "builder_execution_changed",
960
+ statusCode: 409,
961
+ });
962
+ }
740
963
  },
741
964
  claimExecution: async (args) => {
965
+ const prepared = await storedPayloadJson(args.executionId, args.payload);
966
+ let result;
967
+ try {
968
+ result = await getDb().transaction(async (tx) => {
969
+ const transactionDb = tx as unknown as ReturnType<typeof getDb>;
970
+ const [scope] = await tx
971
+ .select({ databaseId: schema.contentDatabaseSources.databaseId })
972
+ .from(schema.contentDatabaseSourceExecutions)
973
+ .innerJoin(
974
+ schema.contentDatabaseSources,
975
+ eq(
976
+ schema.contentDatabaseSourceExecutions.sourceId,
977
+ schema.contentDatabaseSources.id,
978
+ ),
979
+ )
980
+ .where(
981
+ eq(schema.contentDatabaseSourceExecutions.id, args.executionId),
982
+ )
983
+ .limit(1);
984
+ if (!scope) return null;
985
+ await lockContentDatabaseMutation(transactionDb, scope.databaseId);
986
+ const updated = await tx
987
+ .update(schema.contentDatabaseSourceExecutions)
988
+ .set({
989
+ state: "running",
990
+ summary: args.summary,
991
+ payloadJson: prepared.payloadJson,
992
+ lastError: null,
993
+ attemptToken: args.attemptToken,
994
+ updatedAt: args.now,
995
+ })
996
+ .where(
997
+ and(
998
+ eq(schema.contentDatabaseSourceExecutions.id, args.executionId),
999
+ eq(
1000
+ schema.contentDatabaseSourceExecutions.payloadJson,
1001
+ prepared.previousPayloadJson,
1002
+ ),
1003
+ eq(
1004
+ schema.contentDatabaseSourceExecutions.state,
1005
+ prepared.previousState,
1006
+ ),
1007
+ previousAttemptFilter(prepared),
1008
+ eq(
1009
+ schema.contentDatabaseSourceExecutions.state,
1010
+ args.expected.state,
1011
+ ),
1012
+ eq(
1013
+ schema.contentDatabaseSourceExecutions.updatedAt,
1014
+ args.expected.updatedAt,
1015
+ ),
1016
+ previousAttemptFilter({
1017
+ previousAttemptToken: args.expected.attemptToken ?? null,
1018
+ }),
1019
+ exists(
1020
+ tx
1021
+ .select({ id: schema.contentDatabaseSourceChangeSets.id })
1022
+ .from(schema.contentDatabaseSourceChangeSets)
1023
+ .where(
1024
+ and(
1025
+ eq(
1026
+ schema.contentDatabaseSourceChangeSets.id,
1027
+ args.changeSetId,
1028
+ ),
1029
+ eq(
1030
+ schema.contentDatabaseSourceChangeSets.state,
1031
+ "approved",
1032
+ ),
1033
+ ),
1034
+ ),
1035
+ ),
1036
+ or(
1037
+ notInArray(schema.contentDatabaseSourceExecutions.state, [
1038
+ "running",
1039
+ "succeeded",
1040
+ ]),
1041
+ and(
1042
+ eq(schema.contentDatabaseSourceExecutions.state, "running"),
1043
+ lt(
1044
+ schema.contentDatabaseSourceExecutions.updatedAt,
1045
+ args.staleBefore,
1046
+ ),
1047
+ ),
1048
+ ),
1049
+ ),
1050
+ );
1051
+ if (
1052
+ builderExecutionAffectedRows(updated) > 0 &&
1053
+ prepared.previousReference &&
1054
+ prepared.previousReference !== prepared.nextReference
1055
+ ) {
1056
+ await enqueueBuilderPrivatePayloadCleanup(
1057
+ transactionDb,
1058
+ prepared.previousReference,
1059
+ );
1060
+ }
1061
+ return updated;
1062
+ });
1063
+ } catch (error) {
1064
+ if (
1065
+ (await resolveAmbiguousPayloadWrite(args.executionId, prepared)) ===
1066
+ "committed"
1067
+ ) {
1068
+ return true;
1069
+ }
1070
+ throw error;
1071
+ }
1072
+ if (builderExecutionAffectedRows(result) === 0) {
1073
+ await discardUncommittedPayload(prepared);
1074
+ }
1075
+ return builderExecutionAffectedRows(result) > 0;
1076
+ },
1077
+ renewExecutionClaim: async (args) => {
742
1078
  const result = await getDb()
743
1079
  .update(schema.contentDatabaseSourceExecutions)
744
- .set({
745
- state: "running",
746
- summary: args.summary,
747
- payloadJson: JSON.stringify(args.payload),
748
- lastError: null,
749
- attemptToken: args.attemptToken,
750
- updatedAt: args.now,
751
- })
1080
+ .set({ updatedAt: args.now })
752
1081
  .where(
753
1082
  and(
754
1083
  eq(schema.contentDatabaseSourceExecutions.id, args.executionId),
755
- or(
756
- notInArray(schema.contentDatabaseSourceExecutions.state, [
757
- "running",
758
- "succeeded",
759
- ]),
1084
+ eq(schema.contentDatabaseSourceExecutions.state, "running"),
1085
+ eq(
1086
+ schema.contentDatabaseSourceExecutions.attemptToken,
1087
+ args.attemptToken,
1088
+ ),
1089
+ ),
1090
+ )
1091
+ .returning({ id: schema.contentDatabaseSourceExecutions.id });
1092
+ return result.length > 0;
1093
+ },
1094
+ checkpointResponse: async (args) => {
1095
+ const prepared = await storedPayloadJson(args.executionId, args.payload);
1096
+ let result;
1097
+ try {
1098
+ result = await updateStoredPayload(prepared, (tx) =>
1099
+ tx
1100
+ .update(schema.contentDatabaseSourceExecutions)
1101
+ .set({
1102
+ state: "response_received",
1103
+ summary: "Builder response received; reconciling locally.",
1104
+ payloadJson: prepared.payloadJson,
1105
+ lastError: null,
1106
+ updatedAt: args.now,
1107
+ })
1108
+ .where(
760
1109
  and(
1110
+ eq(schema.contentDatabaseSourceExecutions.id, args.executionId),
1111
+ eq(
1112
+ schema.contentDatabaseSourceExecutions.payloadJson,
1113
+ prepared.previousPayloadJson,
1114
+ ),
761
1115
  eq(schema.contentDatabaseSourceExecutions.state, "running"),
762
- lt(
763
- schema.contentDatabaseSourceExecutions.updatedAt,
764
- args.staleBefore,
1116
+ eq(
1117
+ schema.contentDatabaseSourceExecutions.attemptToken,
1118
+ args.attemptToken,
765
1119
  ),
766
1120
  ),
767
1121
  ),
768
- ),
769
1122
  );
1123
+ } catch (error) {
1124
+ if (
1125
+ (await resolveAmbiguousPayloadWrite(args.executionId, prepared)) ===
1126
+ "committed"
1127
+ ) {
1128
+ return true;
1129
+ }
1130
+ throw error;
1131
+ }
1132
+ if (builderExecutionAffectedRows(result) === 0) {
1133
+ await discardUncommittedPayload(prepared);
1134
+ }
770
1135
  return builderExecutionAffectedRows(result) > 0;
771
1136
  },
772
- checkpointResponse: async (args) => {
1137
+ markResponseCheckpointFailed: async (args) => {
773
1138
  const result = await getDb()
774
1139
  .update(schema.contentDatabaseSourceExecutions)
775
1140
  .set({
776
- state: "response_received",
777
- summary: "Builder response received; reconciling locally.",
778
- payloadJson: JSON.stringify(args.payload),
779
- lastError: null,
1141
+ state: "reconciliation_required",
1142
+ summary:
1143
+ "Builder write response could not be stored for reconciliation.",
1144
+ lastError: args.lastError,
780
1145
  updatedAt: args.now,
781
1146
  })
782
1147
  .where(
783
1148
  and(
784
1149
  eq(schema.contentDatabaseSourceExecutions.id, args.executionId),
1150
+ eq(schema.contentDatabaseSourceExecutions.state, "running"),
785
1151
  eq(
786
1152
  schema.contentDatabaseSourceExecutions.attemptToken,
787
1153
  args.attemptToken,
@@ -792,65 +1158,329 @@ export function realExecutionDeps(
792
1158
  },
793
1159
  markExecutionSucceeded: async (args) => {
794
1160
  const db = getDb();
795
- await db.transaction(async (tx) => {
796
- const result = await tx
1161
+ const prepared = await storedPayloadJson(args.executionId, args.payload);
1162
+ try {
1163
+ await db.transaction(async (tx) => {
1164
+ const result = await tx
1165
+ .update(schema.contentDatabaseSourceExecutions)
1166
+ .set({
1167
+ state: "succeeded",
1168
+ summary: args.summary,
1169
+ payloadJson: prepared.payloadJson,
1170
+ lastError: null,
1171
+ updatedAt: args.now,
1172
+ })
1173
+ .where(
1174
+ args.attemptToken
1175
+ ? and(
1176
+ eq(
1177
+ schema.contentDatabaseSourceExecutions.id,
1178
+ args.executionId,
1179
+ ),
1180
+ eq(
1181
+ schema.contentDatabaseSourceExecutions.attemptToken,
1182
+ args.attemptToken,
1183
+ ),
1184
+ eq(
1185
+ schema.contentDatabaseSourceExecutions.payloadJson,
1186
+ prepared.previousPayloadJson,
1187
+ ),
1188
+ eq(
1189
+ schema.contentDatabaseSourceExecutions.state,
1190
+ prepared.previousState,
1191
+ ),
1192
+ )
1193
+ : and(
1194
+ eq(
1195
+ schema.contentDatabaseSourceExecutions.id,
1196
+ args.executionId,
1197
+ ),
1198
+ eq(
1199
+ schema.contentDatabaseSourceExecutions.payloadJson,
1200
+ prepared.previousPayloadJson,
1201
+ ),
1202
+ eq(
1203
+ schema.contentDatabaseSourceExecutions.state,
1204
+ prepared.previousState,
1205
+ ),
1206
+ previousAttemptFilter(prepared),
1207
+ ),
1208
+ );
1209
+ if (builderExecutionAffectedRows(result) === 0) {
1210
+ fail("Execution lease was reclaimed before completion.", {
1211
+ errorCode: "builder_execution_changed",
1212
+ statusCode: 409,
1213
+ });
1214
+ }
1215
+ if (
1216
+ prepared.previousReference &&
1217
+ prepared.previousReference !== prepared.nextReference
1218
+ ) {
1219
+ await enqueueBuilderPrivatePayloadCleanup(
1220
+ tx as unknown as ReturnType<typeof getDb>,
1221
+ prepared.previousReference,
1222
+ );
1223
+ }
1224
+ await tx
1225
+ .update(schema.contentDatabaseSourceChangeSets)
1226
+ .set({ state: "applied", updatedAt: args.now })
1227
+ .where(
1228
+ eq(schema.contentDatabaseSourceChangeSets.id, args.changeSetId),
1229
+ );
1230
+ });
1231
+ } catch (error) {
1232
+ if (
1233
+ (await resolveAmbiguousPayloadWrite(args.executionId, prepared)) ===
1234
+ "committed"
1235
+ ) {
1236
+ return;
1237
+ }
1238
+ throw error;
1239
+ }
1240
+ },
1241
+ markExecutionFailed: async (args) => {
1242
+ let prepared: Awaited<ReturnType<typeof storedPayloadJson>>;
1243
+ try {
1244
+ prepared = await storedPayloadJson(args.executionId, args.payload);
1245
+ } catch (error) {
1246
+ if (args.state !== "reconciliation_required") throw error;
1247
+ const [current] = await getDb()
1248
+ .select()
1249
+ .from(schema.contentDatabaseSourceExecutions)
1250
+ .where(
1251
+ eq(schema.contentDatabaseSourceExecutions.id, args.executionId),
1252
+ )
1253
+ .limit(1);
1254
+ if (!current) throw error;
1255
+ const result = await getDb()
797
1256
  .update(schema.contentDatabaseSourceExecutions)
798
1257
  .set({
799
- state: "succeeded",
1258
+ state: "reconciliation_required",
800
1259
  summary: args.summary,
801
- payloadJson: JSON.stringify(args.payload),
802
- lastError: null,
1260
+ lastError:
1261
+ args.lastError ??
1262
+ "Builder outcome requires reconciliation; payload could not be checkpointed.",
803
1263
  updatedAt: args.now,
804
1264
  })
805
1265
  .where(
806
- args.attemptToken
807
- ? and(
808
- eq(
809
- schema.contentDatabaseSourceExecutions.id,
810
- args.executionId,
811
- ),
812
- eq(
1266
+ and(
1267
+ eq(schema.contentDatabaseSourceExecutions.id, args.executionId),
1268
+ eq(
1269
+ schema.contentDatabaseSourceExecutions.payloadJson,
1270
+ current.payloadJson,
1271
+ ),
1272
+ eq(schema.contentDatabaseSourceExecutions.state, current.state),
1273
+ notInArray(schema.contentDatabaseSourceExecutions.state, [
1274
+ "succeeded",
1275
+ "failed",
1276
+ ]),
1277
+ args.attemptToken
1278
+ ? eq(
813
1279
  schema.contentDatabaseSourceExecutions.attemptToken,
814
1280
  args.attemptToken,
815
- ),
816
- )
817
- : eq(schema.contentDatabaseSourceExecutions.id, args.executionId),
1281
+ )
1282
+ : isNull(schema.contentDatabaseSourceExecutions.attemptToken),
1283
+ ),
1284
+ )
1285
+ .returning({ id: schema.contentDatabaseSourceExecutions.id });
1286
+ if (result.length === 0) {
1287
+ fail(
1288
+ "Builder execution changed before reconciliation was recorded.",
1289
+ {
1290
+ errorCode: "builder_execution_changed",
1291
+ statusCode: 409,
1292
+ },
818
1293
  );
819
- if (args.attemptToken && builderExecutionAffectedRows(result) === 0) {
820
- throw new Error("Execution lease was reclaimed before completion.");
821
1294
  }
822
- await tx
823
- .update(schema.contentDatabaseSourceChangeSets)
824
- .set({ state: "applied", updatedAt: args.now })
1295
+ return;
1296
+ }
1297
+ let result;
1298
+ try {
1299
+ result = await updateStoredPayload(prepared, (tx) =>
1300
+ tx
1301
+ .update(schema.contentDatabaseSourceExecutions)
1302
+ .set({
1303
+ state: args.state ?? "failed",
1304
+ summary: args.summary,
1305
+ payloadJson: prepared.payloadJson,
1306
+ lastError: args.lastError,
1307
+ updatedAt: args.now,
1308
+ })
1309
+ .where(
1310
+ args.attemptToken
1311
+ ? and(
1312
+ eq(
1313
+ schema.contentDatabaseSourceExecutions.id,
1314
+ args.executionId,
1315
+ ),
1316
+ eq(
1317
+ schema.contentDatabaseSourceExecutions.attemptToken,
1318
+ args.attemptToken,
1319
+ ),
1320
+ eq(
1321
+ schema.contentDatabaseSourceExecutions.payloadJson,
1322
+ prepared.previousPayloadJson,
1323
+ ),
1324
+ eq(
1325
+ schema.contentDatabaseSourceExecutions.state,
1326
+ prepared.previousState,
1327
+ ),
1328
+ )
1329
+ : and(
1330
+ eq(
1331
+ schema.contentDatabaseSourceExecutions.id,
1332
+ args.executionId,
1333
+ ),
1334
+ eq(
1335
+ schema.contentDatabaseSourceExecutions.payloadJson,
1336
+ prepared.previousPayloadJson,
1337
+ ),
1338
+ eq(
1339
+ schema.contentDatabaseSourceExecutions.state,
1340
+ prepared.previousState,
1341
+ ),
1342
+ previousAttemptFilter(prepared),
1343
+ ),
1344
+ ),
1345
+ );
1346
+ } catch (error) {
1347
+ if (
1348
+ (await resolveAmbiguousPayloadWrite(args.executionId, prepared)) ===
1349
+ "committed"
1350
+ ) {
1351
+ return;
1352
+ }
1353
+ throw error;
1354
+ }
1355
+ if (builderExecutionAffectedRows(result) === 0) {
1356
+ await discardUncommittedPayload(prepared);
1357
+ fail("Builder execution changed before failure checkpoint.", {
1358
+ errorCode: "builder_execution_changed",
1359
+ statusCode: 409,
1360
+ });
1361
+ }
1362
+ },
1363
+ releaseExecutionClaim: async (args) => {
1364
+ let prepared: Awaited<ReturnType<typeof storedPayloadJson>>;
1365
+ try {
1366
+ prepared = await storedPayloadJson(args.executionId, args.payload);
1367
+ } catch (error) {
1368
+ if (
1369
+ !isActionContractError(error) ||
1370
+ error.errorCode !== "BUILDER_PRIVATE_PAYLOAD_UNAVAILABLE"
1371
+ ) {
1372
+ throw error;
1373
+ }
1374
+ // Nothing was sent, so the stored payload is still accurate. Blob
1375
+ // storage being down must not hold the claim until its lease expires.
1376
+ // Cancelling a blocked gate needs the preflight evidence that could
1377
+ // not be stored; a ready gate stays cancellable and re-checks the
1378
+ // live entry on its next run.
1379
+ const result = await getDb()
1380
+ .update(schema.contentDatabaseSourceExecutions)
1381
+ .set({
1382
+ state:
1383
+ args.restore.state === "blocked" ? "ready" : args.restore.state,
1384
+ summary: args.summary,
1385
+ lastError: args.lastError,
1386
+ attemptToken: args.restore.attemptToken ?? null,
1387
+ updatedAt:
1388
+ args.restore.state === "running"
1389
+ ? args.restore.updatedAt
1390
+ : args.now,
1391
+ })
825
1392
  .where(
826
- eq(schema.contentDatabaseSourceChangeSets.id, args.changeSetId),
1393
+ and(
1394
+ eq(schema.contentDatabaseSourceExecutions.id, args.executionId),
1395
+ eq(schema.contentDatabaseSourceExecutions.state, "running"),
1396
+ eq(
1397
+ schema.contentDatabaseSourceExecutions.attemptToken,
1398
+ args.attemptToken,
1399
+ ),
1400
+ ),
827
1401
  );
828
- });
829
- },
830
- markExecutionFailed: async (args) => {
831
- await getDb()
832
- .update(schema.contentDatabaseSourceExecutions)
833
- .set({
834
- state: args.state ?? "failed",
835
- summary: args.summary,
836
- payloadJson: JSON.stringify(args.payload),
837
- lastError: args.lastError,
838
- updatedAt: args.now,
839
- })
840
- .where(
841
- args.attemptToken
842
- ? and(
1402
+ if (builderExecutionAffectedRows(result) === 0) {
1403
+ fail("Builder execution changed before its claim was released.", {
1404
+ errorCode: "builder_execution_changed",
1405
+ statusCode: 409,
1406
+ });
1407
+ }
1408
+ return;
1409
+ }
1410
+ let result;
1411
+ try {
1412
+ result = await updateStoredPayload(prepared, (tx) =>
1413
+ tx
1414
+ .update(schema.contentDatabaseSourceExecutions)
1415
+ .set({
1416
+ state: args.restore.state,
1417
+ summary: args.summary,
1418
+ payloadJson: prepared.payloadJson,
1419
+ lastError: args.lastError,
1420
+ attemptToken: args.restore.attemptToken ?? null,
1421
+ updatedAt:
1422
+ args.restore.state === "running"
1423
+ ? args.restore.updatedAt
1424
+ : args.now,
1425
+ })
1426
+ .where(
1427
+ and(
843
1428
  eq(schema.contentDatabaseSourceExecutions.id, args.executionId),
1429
+ eq(schema.contentDatabaseSourceExecutions.state, "running"),
844
1430
  eq(
845
1431
  schema.contentDatabaseSourceExecutions.attemptToken,
846
1432
  args.attemptToken,
847
1433
  ),
848
- )
849
- : eq(schema.contentDatabaseSourceExecutions.id, args.executionId),
1434
+ eq(
1435
+ schema.contentDatabaseSourceExecutions.payloadJson,
1436
+ prepared.previousPayloadJson,
1437
+ ),
1438
+ ),
1439
+ ),
850
1440
  );
1441
+ } catch (error) {
1442
+ if (
1443
+ (await resolveAmbiguousPayloadWrite(args.executionId, prepared)) ===
1444
+ "committed"
1445
+ ) {
1446
+ return;
1447
+ }
1448
+ throw error;
1449
+ }
1450
+ if (builderExecutionAffectedRows(result) === 0) {
1451
+ await discardUncommittedPayload(prepared);
1452
+ fail("Builder execution changed before its claim was released.", {
1453
+ errorCode: "builder_execution_changed",
1454
+ statusCode: 409,
1455
+ });
1456
+ }
851
1457
  },
852
1458
  executeWrite: (args) => executeBuilderCmsWrite(args),
853
- readLiveEntry: (args) => readBuilderCmsEntryLiveState(args),
1459
+ readLiveEntry: async (args) => {
1460
+ const controller = new AbortController();
1461
+ let timeout: ReturnType<typeof setTimeout> | undefined;
1462
+ const deadline = new Promise<never>((_resolve, reject) => {
1463
+ timeout = setTimeout(() => {
1464
+ const error = new Error(
1465
+ "Builder live preflight exceeded its time limit.",
1466
+ );
1467
+ controller.abort(error);
1468
+ reject(error);
1469
+ }, BUILDER_LIVE_PREFLIGHT_TIMEOUT_MS);
1470
+ });
1471
+ try {
1472
+ return await Promise.race([
1473
+ readBuilderCmsEntryLiveState({
1474
+ ...args,
1475
+ requireBoundOAuth: true,
1476
+ signal: controller.signal,
1477
+ }),
1478
+ deadline,
1479
+ ]);
1480
+ } finally {
1481
+ if (timeout) clearTimeout(timeout);
1482
+ }
1483
+ },
854
1484
  reconcileWrite: reconcileBuilderCmsWrite,
855
1485
  getResponse: (databaseId) =>
856
1486
  getContentDatabaseResponse(databaseId, { limit: 100, offset: 0 }),
@@ -949,9 +1579,12 @@ export async function executeBuilderSourceExecutionWithDeps(
949
1579
  !isReclaimableRunningExecution(execution, now)
950
1580
  ) {
951
1581
  throw builderExecutionConflict(
952
- execution.state === "reconciliation_required"
953
- ? "Builder reconciliation required; wait for the recovery window before a read-only lookup. Do not retry."
954
- : "Builder execution is already running.",
1582
+ execution.state === "reconciliation_required" &&
1583
+ execution.lastError === BUILDER_RESPONSE_CHECKPOINT_FAILURE
1584
+ ? BUILDER_RESPONSE_CHECKPOINT_FAILURE
1585
+ : execution.state === "reconciliation_required"
1586
+ ? "Builder reconciliation required; wait for the recovery window before a read-only lookup. Do not retry."
1587
+ : "Builder execution is already running.",
955
1588
  );
956
1589
  }
957
1590
 
@@ -996,6 +1629,11 @@ export async function executeBuilderSourceExecutionWithDeps(
996
1629
  : (plan.lastError ?? "Builder execution is blocked.");
997
1630
  await deps.updateExecutionState({
998
1631
  executionId: execution.id,
1632
+ expected: {
1633
+ state: execution.state,
1634
+ updatedAt: execution.updatedAt,
1635
+ attemptToken: execution.attemptToken,
1636
+ },
999
1637
  state: "blocked",
1000
1638
  summary: `${plan.summary} Execution blocked before write.`,
1001
1639
  payload: validatedPayload,
@@ -1008,6 +1646,11 @@ export async function executeBuilderSourceExecutionWithDeps(
1008
1646
  const message = plan.lastError ?? "Builder execution is not ready.";
1009
1647
  await deps.updateExecutionState({
1010
1648
  executionId: execution.id,
1649
+ expected: {
1650
+ state: execution.state,
1651
+ updatedAt: execution.updatedAt,
1652
+ attemptToken: execution.attemptToken,
1653
+ },
1011
1654
  state: plan.state,
1012
1655
  summary: plan.summary,
1013
1656
  payload: validatedPayload,
@@ -1021,6 +1664,11 @@ export async function executeBuilderSourceExecutionWithDeps(
1021
1664
  const message = "Live Builder writes are disabled for this source.";
1022
1665
  await deps.updateExecutionState({
1023
1666
  executionId: execution.id,
1667
+ expected: {
1668
+ state: execution.state,
1669
+ updatedAt: execution.updatedAt,
1670
+ attemptToken: execution.attemptToken,
1671
+ },
1024
1672
  state: "write_disabled",
1025
1673
  summary: `${plan.summary} Execution blocked before write.`,
1026
1674
  payload: validatedPayload,
@@ -1037,10 +1685,14 @@ export async function executeBuilderSourceExecutionWithDeps(
1037
1685
  ? {
1038
1686
  exactTitle: storedIntent?.exactTitle,
1039
1687
  intendedFields: storedIntent?.intendedFields,
1688
+ expectedSourceSpace: source.metadata.builderSpacePublicKey,
1689
+ expectedSourceConnectionId: source.metadata.connectionId,
1040
1690
  }
1041
1691
  : {
1042
1692
  marker: planIntent.marker,
1043
1693
  intendedFields: planIntent.intendedFields,
1694
+ expectedSourceSpace: source.metadata.builderSpacePublicKey,
1695
+ expectedSourceConnectionId: source.metadata.connectionId,
1044
1696
  },
1045
1697
  );
1046
1698
  const matchingIntentCount = lookup.matchingIntentCount ?? lookup.count;
@@ -1123,31 +1775,117 @@ export async function executeBuilderSourceExecutionWithDeps(
1123
1775
  return result;
1124
1776
  }
1125
1777
 
1126
- if (requiresLivePreflight(plan.payload.effect)) {
1127
- const entryId = plan.payload.target.entryId;
1128
- if (!entryId) {
1129
- const message = "Builder entry no longer exists; refresh the source.";
1130
- await deps.updateExecutionState({
1778
+ const preflightEntryId = requiresLivePreflight(plan.payload.effect)
1779
+ ? plan.payload.target.entryId
1780
+ : null;
1781
+ if (requiresLivePreflight(plan.payload.effect) && !preflightEntryId) {
1782
+ const message = "Builder entry no longer exists; refresh the source.";
1783
+ await deps.updateExecutionState({
1784
+ executionId: execution.id,
1785
+ expected: {
1786
+ state: execution.state,
1787
+ updatedAt: execution.updatedAt,
1788
+ attemptToken: execution.attemptToken,
1789
+ },
1790
+ state: "blocked",
1791
+ summary: `${plan.summary} Execution blocked before write.`,
1792
+ payload: validatedPayload,
1793
+ lastError: message,
1794
+ now,
1795
+ });
1796
+ throw new Error(message);
1797
+ }
1798
+
1799
+ const attemptToken = crypto.randomUUID();
1800
+ const claimed = await deps.claimExecution({
1801
+ executionId: execution.id,
1802
+ changeSetId: changeSet.id,
1803
+ expected: {
1804
+ state: execution.state,
1805
+ updatedAt: execution.updatedAt,
1806
+ attemptToken: execution.attemptToken,
1807
+ },
1808
+ summary: `Running Builder ${plan.pushMode} execution.`,
1809
+ payload: validatedPayload,
1810
+ now,
1811
+ staleBefore: staleRunningCutoff(now),
1812
+ attemptToken,
1813
+ });
1814
+ if (!claimed) {
1815
+ throw builderExecutionConflict("Builder execution is already running.");
1816
+ }
1817
+ // Nothing has reached Builder until executeWrite returns a result, so any
1818
+ // stop before then hands the gate back instead of stranding a claim that
1819
+ // cancel and review must treat as an attempted write.
1820
+ const releaseClaim = (args: {
1821
+ state: BuilderSourceExecutionRecord["state"];
1822
+ summary: string;
1823
+ payload: unknown;
1824
+ lastError: string;
1825
+ }) =>
1826
+ deps.releaseExecutionClaim({
1827
+ executionId: execution.id,
1828
+ attemptToken,
1829
+ restore: {
1830
+ state: args.state,
1831
+ attemptToken: execution.attemptToken,
1832
+ updatedAt: execution.updatedAt,
1833
+ },
1834
+ summary: args.summary,
1835
+ payload: args.payload,
1836
+ lastError: args.lastError,
1837
+ now: deps.now(),
1838
+ });
1839
+
1840
+ if (preflightEntryId) {
1841
+ // This check runs after the claim so it sits as close to the write as
1842
+ // possible. A Builder save that lands after this read and before the
1843
+ // write below is still overwritten: the check narrows that race but
1844
+ // cannot close it without a conditional write on Builder's side.
1845
+ let liveState: BuilderCmsEntryLiveState;
1846
+ try {
1847
+ liveState = await deps.readLiveEntry({
1848
+ model: plan.payload.target.model,
1849
+ entryId: preflightEntryId,
1850
+ expectedSourceSpace: source.metadata.builderSpacePublicKey,
1851
+ expectedSourceConnectionId: source.metadata.connectionId,
1852
+ });
1853
+ } catch (error) {
1854
+ await releaseClaim({
1855
+ state: execution.state,
1856
+ summary: `${plan.summary} Execution stopped before write.`,
1857
+ payload: validatedPayload,
1858
+ lastError: error instanceof Error ? error.message : String(error),
1859
+ });
1860
+ throw error;
1861
+ }
1862
+ let claimRenewed: boolean;
1863
+ try {
1864
+ claimRenewed = await deps.renewExecutionClaim({
1131
1865
  executionId: execution.id,
1132
- state: "blocked",
1133
- summary: `${plan.summary} Execution blocked before write.`,
1866
+ attemptToken,
1867
+ now: deps.now(),
1868
+ });
1869
+ } catch (error) {
1870
+ await releaseClaim({
1871
+ state: execution.state,
1872
+ summary: `${plan.summary} Execution stopped before write.`,
1134
1873
  payload: validatedPayload,
1135
- lastError: message,
1136
- now,
1874
+ lastError: error instanceof Error ? error.message : String(error),
1137
1875
  });
1138
- throw new Error(message);
1876
+ throw error;
1139
1877
  }
1140
-
1141
- const liveState = await deps.readLiveEntry({
1142
- model: plan.payload.target.model,
1143
- entryId,
1144
- });
1145
- const targetRow = sourceRowForChangeSet(source, changeSet);
1878
+ if (!claimRenewed) {
1879
+ throw builderExecutionConflict(
1880
+ "Builder execution lease was reclaimed during live preflight.",
1881
+ );
1882
+ }
1883
+ const baselineLastUpdated = plan.payload.target.reviewedLastUpdated;
1146
1884
  console.info("builder_source_live_preflight", {
1147
1885
  executionId: execution.id,
1148
1886
  changeSetId: changeSet.id,
1149
- targetEntryId: entryId,
1150
- baselineLastUpdated: targetRow?.lastSourceUpdatedAt ?? null,
1887
+ targetEntryId: preflightEntryId,
1888
+ baselineLastUpdated,
1151
1889
  liveLastUpdated: liveState.lastUpdated,
1152
1890
  baselineBlocksHash: changeSet.bodyChange?.currentHash ?? null,
1153
1891
  liveBlocksHash: liveState.blocksHash,
@@ -1155,13 +1893,12 @@ export async function executeBuilderSourceExecutionWithDeps(
1155
1893
  });
1156
1894
  const message = livePreflightBlockMessage({
1157
1895
  liveState,
1158
- baselineLastUpdated: targetRow?.lastSourceUpdatedAt ?? null,
1896
+ baselineLastUpdated,
1159
1897
  baselineBlocksHash: changeSet.bodyChange?.currentHash ?? null,
1160
1898
  effect: plan.payload.effect,
1161
1899
  });
1162
1900
  if (message) {
1163
- await deps.updateExecutionState({
1164
- executionId: execution.id,
1901
+ await releaseClaim({
1165
1902
  state: "blocked",
1166
1903
  summary: `${plan.summary} Execution blocked before write.`,
1167
1904
  payload: {
@@ -1176,29 +1913,35 @@ export async function executeBuilderSourceExecutionWithDeps(
1176
1913
  },
1177
1914
  },
1178
1915
  lastError: message,
1179
- now,
1180
1916
  });
1181
- throw new Error(message);
1917
+ fail(message, {
1918
+ errorCode: "builder_entry_changed_since_review",
1919
+ statusCode: 409,
1920
+ });
1182
1921
  }
1183
1922
  }
1184
-
1185
- const attemptToken = crypto.randomUUID();
1186
- const claimed = await deps.claimExecution({
1187
- executionId: execution.id,
1188
- summary: `Running Builder ${plan.pushMode} execution.`,
1189
- payload: validatedPayload,
1190
- now,
1191
- staleBefore: staleRunningCutoff(now),
1192
- attemptToken,
1193
- });
1194
- if (!claimed) {
1195
- throw builderExecutionConflict("Builder execution is already running.");
1196
- }
1197
1923
  timing.record("approval_gate_and_dry_run_validation", gateStartedAt);
1198
1924
 
1199
- const writeResult = await timing.measure("write_dispatch", () =>
1200
- deps.executeWrite({ request: plan.payload.request }),
1201
- );
1925
+ let writeResult: BuilderCmsWriteResult;
1926
+ try {
1927
+ writeResult = await timing.measure("write_dispatch", () =>
1928
+ deps.executeWrite({
1929
+ request: plan.payload.request,
1930
+ expectedSourceSpace: source.metadata.builderSpacePublicKey,
1931
+ expectedSourceConnectionId: source.metadata.connectionId,
1932
+ requireSourceBinding: true,
1933
+ }),
1934
+ );
1935
+ } catch (error) {
1936
+ // executeWrite reports every post-dispatch outcome as a result.
1937
+ await releaseClaim({
1938
+ state: execution.state,
1939
+ summary: `${plan.summary} Execution stopped before write.`,
1940
+ payload: validatedPayload,
1941
+ lastError: error instanceof Error ? error.message : String(error),
1942
+ });
1943
+ throw error;
1944
+ }
1202
1945
  const payloadWithResponse = executionResponsePayload({
1203
1946
  payload: validatedPayload,
1204
1947
  writeResult,
@@ -1230,27 +1973,44 @@ export async function executeBuilderSourceExecutionWithDeps(
1230
1973
  }
1231
1974
 
1232
1975
  const reconciliationStartedAt = timing.start();
1233
- if (deps.checkpointResponse) {
1234
- const checkpointed = await deps.checkpointResponse({
1976
+ let checkpointed = true;
1977
+ try {
1978
+ if (deps.checkpointResponse) {
1979
+ checkpointed = await deps.checkpointResponse({
1980
+ executionId: execution.id,
1981
+ attemptToken,
1982
+ payload: payloadWithResponse,
1983
+ now: deps.now(),
1984
+ });
1985
+ } else {
1986
+ await deps.updateExecutionState({
1987
+ executionId: execution.id,
1988
+ expected: { state: "running", updatedAt: now, attemptToken },
1989
+ state: "response_received",
1990
+ summary: "Builder response received; reconciling locally.",
1991
+ payload: payloadWithResponse,
1992
+ lastError: null,
1993
+ now: deps.now(),
1994
+ });
1995
+ }
1996
+ } catch {
1997
+ const recorded = await deps.markResponseCheckpointFailed({
1235
1998
  executionId: execution.id,
1236
1999
  attemptToken,
1237
- payload: payloadWithResponse,
2000
+ lastError: BUILDER_RESPONSE_CHECKPOINT_FAILURE,
1238
2001
  now: deps.now(),
1239
2002
  });
1240
- if (!checkpointed) {
1241
- throw new Error(
1242
- "Execution lease was reclaimed before response checkpoint.",
2003
+ if (!recorded) {
2004
+ throw builderExecutionConflict(
2005
+ "Execution lease was reclaimed before response custody could be recorded.",
1243
2006
  );
1244
2007
  }
1245
- } else {
1246
- await deps.updateExecutionState({
1247
- executionId: execution.id,
1248
- state: "response_received",
1249
- summary: "Builder response received; reconciling locally.",
1250
- payload: payloadWithResponse,
1251
- lastError: null,
1252
- now: deps.now(),
1253
- });
2008
+ throw builderExecutionConflict(BUILDER_RESPONSE_CHECKPOINT_FAILURE);
2009
+ }
2010
+ if (!checkpointed) {
2011
+ throw builderExecutionConflict(
2012
+ "Execution lease was reclaimed before response checkpoint.",
2013
+ );
1254
2014
  }
1255
2015
 
1256
2016
  const succeededAt = deps.now();