@agent-native/core-corpus 0.216.0 → 0.217.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (149) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/templates/analytics/.agents/skills/session-replay/SKILL.md +61 -1
  3. package/corpus/templates/analytics/AGENTS.md +1 -1
  4. package/corpus/templates/analytics/CHANGELOG.md +1 -0
  5. package/corpus/templates/analytics/actions/get-session-replay-summary.ts +9 -65
  6. package/corpus/templates/analytics/actions/list-event-catalog.ts +1 -1
  7. package/corpus/templates/analytics/actions/list-session-outputs.ts +40 -0
  8. package/corpus/templates/analytics/actions/list-session-recordings.ts +17 -4
  9. package/corpus/templates/analytics/actions/view-screen.ts +29 -3
  10. package/corpus/templates/analytics/app/i18n/en-US.ts +27 -0
  11. package/corpus/templates/analytics/app/pages/sessions/EventCatalogPage.tsx +18 -1
  12. package/corpus/templates/analytics/app/pages/sessions/SessionDetailPage.tsx +3 -1
  13. package/corpus/templates/analytics/app/pages/sessions/SessionOutputs.tsx +197 -0
  14. package/corpus/templates/analytics/app/pages/sessions/SessionsTriagePage.tsx +114 -4
  15. package/corpus/templates/analytics/changelog/2026-10-15-sessions-can-find-people-who-made-a-slides-deck-but-didn-t-p.md +6 -0
  16. package/corpus/templates/analytics/changelog/2026-10-16-the-activation-funnel-counts-visitors-who-decline-analytics-.md +6 -0
  17. package/corpus/templates/analytics/server/db/schema.ts +67 -0
  18. package/corpus/templates/analytics/server/lib/canonical-first-party-dashboard-repair.ts +9 -0
  19. package/corpus/templates/analytics/server/lib/first-party-analytics.ts +28 -22
  20. package/corpus/templates/analytics/server/lib/first-party-metric-catalog.ts +36 -1
  21. package/corpus/templates/analytics/server/lib/output-outcomes.ts +712 -0
  22. package/corpus/templates/analytics/server/lib/replay-origins.ts +33 -0
  23. package/corpus/templates/analytics/server/lib/session-event-index.ts +20 -0
  24. package/corpus/templates/analytics/server/lib/session-performance.ts +6 -40
  25. package/corpus/templates/analytics/server/lib/session-recording-associations.ts +30 -1
  26. package/corpus/templates/analytics/server/lib/session-replay-triage.ts +99 -0
  27. package/corpus/templates/analytics/server/lib/session-replay.ts +45 -31
  28. package/corpus/templates/analytics/server/lib/sessions-triage-lab.ts +8 -2
  29. package/corpus/templates/analytics/server/plugins/db.ts +46 -0
  30. package/corpus/templates/analytics/shared/output-use.ts +205 -0
  31. package/corpus/templates/analytics/shared/session-events.ts +6 -0
  32. package/corpus/templates/calendar/CHANGELOG.md +6 -0
  33. package/corpus/templates/calendar/actions/create-event.ts +2 -4
  34. package/corpus/templates/calendar/actions/update-event.ts +35 -6
  35. package/corpus/templates/calendar/changelog/2026-10-21-solo-events-avoid-notification-approval.md +6 -0
  36. package/corpus/templates/community/qr/package.json +1 -1
  37. package/corpus/templates/content/.agents/skills/document-editing/references/databases.md +6 -3
  38. package/corpus/templates/content/CHANGELOG.md +8 -0
  39. package/corpus/templates/content/actions/_browser-document-save-attempt.ts +23 -1
  40. package/corpus/templates/content/actions/_builder-cms-blob-custody.ts +467 -0
  41. package/corpus/templates/content/actions/_builder-cms-execution-lease.ts +17 -0
  42. package/corpus/templates/content/actions/_builder-cms-intent-lookup.ts +5 -0
  43. package/corpus/templates/content/actions/_builder-cms-read-client.ts +881 -21
  44. package/corpus/templates/content/actions/_builder-cms-write-adapter.ts +28 -2
  45. package/corpus/templates/content/actions/_builder-cms-write-client.ts +109 -11
  46. package/corpus/templates/content/actions/_builder-docs-client.ts +1 -0
  47. package/corpus/templates/content/actions/_builder-private-blob.test-fixture.ts +90 -0
  48. package/corpus/templates/content/actions/_builder-source-execution-guard.ts +48 -0
  49. package/corpus/templates/content/actions/_content-save-outcomes.ts +5 -0
  50. package/corpus/templates/content/actions/_database-source-utils.ts +290 -8
  51. package/corpus/templates/content/actions/_database-utils.ts +93 -7
  52. package/corpus/templates/content/actions/_database-view-setup.ts +21 -2
  53. package/corpus/templates/content/actions/_delete-content-space.ts +8 -1
  54. package/corpus/templates/content/actions/_github-source-freshness.ts +30 -4
  55. package/corpus/templates/content/actions/add-content-database-source-field-property.ts +23 -6
  56. package/corpus/templates/content/actions/cancel-prepared-builder-source-update.ts +110 -26
  57. package/corpus/templates/content/actions/change-content-database-source-role.ts +51 -7
  58. package/corpus/templates/content/actions/delete-document.ts +41 -3
  59. package/corpus/templates/content/actions/disconnect-content-database-source.ts +68 -2
  60. package/corpus/templates/content/actions/disconnect-local-folder-source.ts +33 -2
  61. package/corpus/templates/content/actions/execute-builder-source-execution.ts +900 -140
  62. package/corpus/templates/content/actions/get-document.ts +2 -0
  63. package/corpus/templates/content/actions/permanently-delete-document.ts +6 -0
  64. package/corpus/templates/content/actions/prepare-builder-source-execution.ts +155 -13
  65. package/corpus/templates/content/actions/prepare-builder-source-review.ts +299 -73
  66. package/corpus/templates/content/actions/set-live-body-switch.ts +45 -0
  67. package/corpus/templates/content/actions/update-document.ts +139 -20
  68. package/corpus/templates/content/actions/validate-builder-source-execution.ts +135 -26
  69. package/corpus/templates/content/app/components/editor/database/DatabaseView.tsx +23 -0
  70. package/corpus/templates/content/app/components/editor/database/GitHubSourceStatusLine.tsx +27 -2
  71. package/corpus/templates/content/app/components/editor/database/ListItemLayout.tsx +32 -8
  72. package/corpus/templates/content/app/components/editor/database/ListItemRow.tsx +27 -2
  73. package/corpus/templates/content/app/components/editor/database-sources/BuilderSourceReviewDialog.tsx +1 -0
  74. package/corpus/templates/content/app/hooks/use-content-database.ts +40 -2
  75. package/corpus/templates/content/app/hooks/use-documents.ts +14 -0
  76. package/corpus/templates/content/app/i18n-data.ts +1 -0
  77. package/corpus/templates/content/changelog/2026-10-17-builder-content-avoids-retrying-a-write-when-builder-accepte.md +6 -0
  78. package/corpus/templates/content/changelog/2026-10-18-link-a-new-collection-to-a-github-folder-from-its-sources.md +1 -1
  79. package/corpus/templates/content/changelog/2026-10-20-builder-content-sync-keeps-read-and-write-status-accurate-wh.md +6 -0
  80. package/corpus/templates/content/changelog/2026-10-21-list-rows-can-show-each-pages-first-lines.md +6 -0
  81. package/corpus/templates/content/changelog/2026-10-22-a-collection-linked-to-a-big-github-folder-fills-faster-and-p.md +6 -0
  82. package/corpus/templates/content/docs/product/capabilities/content.source.adapters.md +29 -0
  83. package/corpus/templates/content/parity/matrix.md +42 -42
  84. package/corpus/templates/content/parity/matrix.ts +3 -1
  85. package/corpus/templates/content/server/db/schema.ts +20 -0
  86. package/corpus/templates/content/server/lib/content-trash-purge.ts +15 -1
  87. package/corpus/templates/content/server/lib/live-body-writer.ts +138 -26
  88. package/corpus/templates/content/server/live-copy/binding.ts +31 -0
  89. package/corpus/templates/content/server/live-copy/bounded-task.d.ts +6 -0
  90. package/corpus/templates/content/server/live-copy/exec-db.ts +1 -0
  91. package/corpus/templates/content/server/live-copy/holds.ts +59 -0
  92. package/corpus/templates/content/server/live-copy/persist.ts +656 -0
  93. package/corpus/templates/content/server/live-copy/switch.ts +136 -0
  94. package/corpus/templates/content/server/live-copy/task-names.ts +4 -0
  95. package/corpus/templates/content/server/live-copy/tasks.ts +183 -0
  96. package/corpus/templates/content/server/live-copy/write-cause.ts +85 -0
  97. package/corpus/templates/content/server/live-copy/write.ts +85 -0
  98. package/corpus/templates/content/server/plugins/builder-private-payload-cleanup.ts +22 -0
  99. package/corpus/templates/content/server/plugins/collab.ts +3 -0
  100. package/corpus/templates/content/server/plugins/db.ts +28 -0
  101. package/corpus/templates/content/shared/api.ts +30 -1
  102. package/corpus/templates/content/shared/database-item-layout.ts +54 -22
  103. package/corpus/templates/content/shared/document-intent-merge.ts +16 -9
  104. package/corpus/templates/content/shared/og-description.ts +258 -36
  105. package/corpus/templates/design/.agents/skills/design-templates/SKILL.md +5 -1
  106. package/corpus/templates/design/.generated/bridge/shader-runtime.generated.ts +25 -4
  107. package/corpus/templates/design/CHANGELOG.md +2 -0
  108. package/corpus/templates/design/actions/get-getting-started.ts +53 -0
  109. package/corpus/templates/design/app/components/design/DesignCanvas.tsx +10 -0
  110. package/corpus/templates/design/app/components/design/bridge/native-shader-runtime.bridge.ts +132 -79
  111. package/corpus/templates/design/app/components/design/bridge/native-source-provider.ts +268 -130
  112. package/corpus/templates/design/app/components/design/bridge/native-worker-scene-host.ts +122 -42
  113. package/corpus/templates/design/app/components/design/bridge/shader-runtime.bridge.ts +41 -4
  114. package/corpus/templates/design/app/components/design/design-canvas/text-content-change-result.ts +46 -12
  115. package/corpus/templates/design/app/components/home/DesignHomeLibrary.tsx +15 -8
  116. package/corpus/templates/design/app/components/layout/GettingStarted.tsx +50 -0
  117. package/corpus/templates/design/app/components/layout/Sidebar.tsx +3 -0
  118. package/corpus/templates/design/app/components/templates/DesignTemplateGrid.tsx +163 -0
  119. package/corpus/templates/design/app/components/templates/DesignTemplateLibrary.tsx +65 -78
  120. package/corpus/templates/design/app/components/templates/TemplatePreview.tsx +6 -2
  121. package/corpus/templates/design/app/global.css +6 -0
  122. package/corpus/templates/design/app/i18n/en-US.ts +17 -0
  123. package/corpus/templates/design/app/lib/canvas-load-outcome.ts +147 -0
  124. package/corpus/templates/design/app/lib/canvas-outcome-tracking.ts +531 -0
  125. package/corpus/templates/design/app/lib/canvas-style-kind.ts +50 -0
  126. package/corpus/templates/design/app/pages/Index.tsx +10 -3
  127. package/corpus/templates/design/app/pages/design-editor/commands/commit-visual-styles.ts +86 -29
  128. package/corpus/templates/design/app/pages/design-editor/commands/save-file-content.ts +38 -3
  129. package/corpus/templates/design/app/pages/design-editor/domains/use-editor-core.ts +9 -0
  130. package/corpus/templates/design/app/pages/design-editor/domains/use-editor-files-and-saving.ts +2 -0
  131. package/corpus/templates/design/changelog/2026-10-21-a-getting-started-checklist-at-the-bottom-of-the-sidebar-tra.md +6 -0
  132. package/corpus/templates/design/changelog/2026-10-21-templates-now-open-a-read-only-preview-from-the-card-and-the.md +6 -0
  133. package/corpus/templates/design/changelog/2026-10-22-collapsed-shader-layers-no-longer-interrupt-other-shaders-on.md +6 -0
  134. package/corpus/templates/design/changelog/2026-10-22-shader-fills-keep-rendering-when-other-screen-content-cannot.md +6 -0
  135. package/corpus/templates/design/scripts/design-export-validation/export-baseline.json +24 -0
  136. package/corpus/templates/design/server/lib/getting-started.ts +267 -0
  137. package/corpus/templates/design/server/plugins/getting-started.ts +5 -0
  138. package/corpus/templates/design/shared/design-template-presets/analytics-dashboard.ts +127 -0
  139. package/corpus/templates/design/shared/design-template-presets/design-studio.ts +98 -0
  140. package/corpus/templates/design/shared/design-template-presets/messaging-app.ts +101 -0
  141. package/corpus/templates/design/shared/design-template-presets/project-tasks.ts +120 -0
  142. package/corpus/templates/design/shared/design-template-presets.ts +8 -0
  143. package/corpus/templates/forms/server/plugins/agent-chat.ts +1 -1
  144. package/corpus/templates/slides/CHANGELOG.md +6 -0
  145. package/corpus/templates/slides/actions/delete-deck.ts +7 -0
  146. package/corpus/templates/slides/changelog/2026-10-21-connected-deck-deletion-requires-approval.md +6 -0
  147. package/corpus/templates/slides/scripts/edit-fidelity/client-action.ts +61 -0
  148. package/corpus/templates/slides/scripts/edit-fidelity/run.ts +10 -0
  149. package/package.json +1 -1
@@ -1,7 +1,7 @@
1
- import { defineAction } from "@agent-native/core/action";
1
+ import { defineAction, fail } from "@agent-native/core/action";
2
2
  import { getRequestUserEmail } from "@agent-native/core/server/request-context";
3
3
  import { assertAccess } from "@agent-native/core/sharing";
4
- import { and, eq, inArray, or } from "drizzle-orm";
4
+ import { and, eq, inArray, isNull, or } from "drizzle-orm";
5
5
  import { z } from "zod";
6
6
 
7
7
  import { getDb, schema } from "../server/db/index.js";
@@ -16,6 +16,13 @@ import type {
16
16
  PrepareBuilderSourceReviewRequest,
17
17
  PrepareBuilderSourceReviewResponse,
18
18
  } from "../shared/api.js";
19
+ import {
20
+ builderExecutionPayloadReference,
21
+ cleanupBuilderPrivatePayload,
22
+ enqueueBuilderPrivatePayloadCleanup,
23
+ readBuilderExecutionPayload,
24
+ storeBuilderExecutionPayload,
25
+ } from "./_builder-cms-blob-custody.js";
19
26
  import {
20
27
  buildBuilderCmsExecutionPlan,
21
28
  resolveBuilderCmsWriteEffect,
@@ -25,7 +32,6 @@ import { claimBuilderSourceExecutionGate } from "./_builder-source-execution-cla
25
32
  import { shouldPreserveBuilderExecution } from "./_builder-source-execution-preservation.js";
26
33
  import { createBuilderSourceTiming } from "./_builder-source-timings.js";
27
34
  import {
28
- canRefreshLocallyBlockedBuilderReview,
29
35
  findOpenSourceChangeSet,
30
36
  getContentDatabaseSourceSnapshotForReview,
31
37
  getContentDatabaseSourceSnapshotForWrite,
@@ -140,17 +146,6 @@ export function reviewPreparePriority(
140
146
  return statePriority + effectPriority;
141
147
  }
142
148
 
143
- function parsePayload(value: string) {
144
- try {
145
- const parsed = JSON.parse(value) as unknown;
146
- return parsed && typeof parsed === "object" && !Array.isArray(parsed)
147
- ? (parsed as Record<string, unknown>)
148
- : {};
149
- } catch {
150
- return {};
151
- }
152
- }
153
-
154
149
  function dryRunStatus(execution: ContentDatabaseSourceExecution | null) {
155
150
  const dryRun =
156
151
  execution?.payload.dryRun &&
@@ -188,10 +183,18 @@ export function buildBuilderSourceReviewPayload(args: {
188
183
  const changedTitle =
189
184
  changeSet.fieldChanges.find((field) => field.localFieldKey === "title")
190
185
  ?.proposedValue ?? null;
191
- const effect = resolveBuilderCmsWriteEffect({
192
- source: args.source,
193
- changeSet,
194
- });
186
+ const plannedEffect = latestExecution?.payload.effect;
187
+ const effect =
188
+ plannedEffect === "autosave" ||
189
+ plannedEffect === "update_in_place" ||
190
+ plannedEffect === "create_draft" ||
191
+ plannedEffect === "publish" ||
192
+ plannedEffect === "unpublish"
193
+ ? plannedEffect
194
+ : resolveBuilderCmsWriteEffect({
195
+ source: args.source,
196
+ changeSet,
197
+ });
195
198
 
196
199
  return {
197
200
  changeSetId: changeSet.id,
@@ -221,9 +224,10 @@ export function buildBuilderSourceReviewPayload(args: {
221
224
  .filter(Boolean);
222
225
  const hasExecutionEvidence =
223
226
  statuses.length > 0 || executionStates.length > 0;
224
- const resultStatus =
225
- executionStates.length > 0 &&
226
- executionStates.every((state) => state === "succeeded")
227
+ const resultStatus = rows.some((row) => row.execution?.payloadUnavailable)
228
+ ? "blocked"
229
+ : executionStates.length > 0 &&
230
+ executionStates.every((state) => state === "succeeded")
227
231
  ? "succeeded"
228
232
  : executionStates.includes("reconciliation_required") ||
229
233
  executionStates.includes("response_received")
@@ -275,7 +279,9 @@ export function buildBuilderSourceReviewPayload(args: {
275
279
  : "Ready to send to Builder."
276
280
  : "Push checked successfully. Nothing was sent to Builder."
277
281
  : resultStatus === "blocked"
278
- ? "Push needs attention before anything can be sent to Builder."
282
+ ? rows.some((row) => row.execution?.payloadUnavailable)
283
+ ? "A prepared Builder payload is unavailable. Retry loading the review before anything can be sent."
284
+ : "Push needs attention before anything can be sent to Builder."
279
285
  : resultStatus === "stale"
280
286
  ? "Push needs a fresh review because the plan changed."
281
287
  : "Builder writes are off in this local build. Push will check the update only.",
@@ -346,23 +352,11 @@ async function approveChangeSetForReview(args: {
346
352
  : null);
347
353
 
348
354
  if (existing) {
349
- const existingExecutions = await db
350
- .select({
351
- state: schema.contentDatabaseSourceExecutions.state,
352
- payloadJson: schema.contentDatabaseSourceExecutions.payloadJson,
353
- attemptToken: schema.contentDatabaseSourceExecutions.attemptToken,
354
- })
355
- .from(schema.contentDatabaseSourceExecutions)
356
- .where(
357
- and(
358
- eq(schema.contentDatabaseSourceExecutions.sourceId, args.sourceId),
359
- eq(schema.contentDatabaseSourceExecutions.changeSetId, existing.id),
360
- ),
361
- );
362
- const mayRefreshApprovedPayload =
363
- canRefreshLocallyBlockedBuilderReview(existingExecutions);
364
-
365
- if (existing.state === "approved" && !mayRefreshApprovedPayload) {
355
+ // An approved payload is immutable execution evidence. Even a locally
356
+ // blocked gate can be claimed after this read, so a materially changed
357
+ // proposal always receives a new revision identity instead of rewriting
358
+ // the proposal beneath an in-flight execution.
359
+ if (existing.state === "approved") {
366
360
  if (existingPayloadMatches(existing)) {
367
361
  return {
368
362
  id: existing.id,
@@ -382,11 +376,9 @@ async function approveChangeSetForReview(args: {
382
376
  },
383
377
  };
384
378
  }
385
- // A failed, dispatched, or otherwise non-refreshable gate owns its exact
386
- // approved payload forever. A materially different local edit must fall
387
- // through to a new revision identity instead of aliasing that evidence.
379
+ // Fall through to a revision-bound change-set ID below.
388
380
  } else {
389
- await db
381
+ const [updated] = await db
390
382
  .update(schema.contentDatabaseSourceChangeSets)
391
383
  .set({
392
384
  direction: "outbound",
@@ -400,7 +392,29 @@ async function approveChangeSetForReview(args: {
400
392
  : null,
401
393
  updatedAt: args.now,
402
394
  })
403
- .where(eq(schema.contentDatabaseSourceChangeSets.id, existing.id));
395
+ .where(
396
+ and(
397
+ eq(schema.contentDatabaseSourceChangeSets.id, existing.id),
398
+ eq(schema.contentDatabaseSourceChangeSets.state, existing.state),
399
+ eq(
400
+ schema.contentDatabaseSourceChangeSets.fieldChangesJson,
401
+ existing.fieldChangesJson,
402
+ ),
403
+ existing.bodyChangeJson === null
404
+ ? isNull(schema.contentDatabaseSourceChangeSets.bodyChangeJson)
405
+ : eq(
406
+ schema.contentDatabaseSourceChangeSets.bodyChangeJson,
407
+ existing.bodyChangeJson,
408
+ ),
409
+ ),
410
+ )
411
+ .returning({ id: schema.contentDatabaseSourceChangeSets.id });
412
+ if (!updated) {
413
+ fail("Builder change set changed during approval.", {
414
+ errorCode: "builder_change_set_changed",
415
+ statusCode: 409,
416
+ });
417
+ }
404
418
  if (existing.state !== "approved") {
405
419
  await db.insert(schema.contentDatabaseSourceChangeReviews).values({
406
420
  id: crypto.randomUUID(),
@@ -504,18 +518,114 @@ async function upsertExecutionGate(args: {
504
518
  return;
505
519
  }
506
520
  if (existing) {
507
- await db
508
- .update(schema.contentDatabaseSourceExecutions)
509
- .set({
510
- state: plan.state,
511
- summary: plan.summary,
512
- payloadJson: JSON.stringify(plan.payload),
513
- lastError: plan.lastError,
514
- updatedAt: args.now,
515
- })
516
- .where(eq(schema.contentDatabaseSourceExecutions.id, existing.id));
521
+ const payloadJson = await storeBuilderExecutionPayload({
522
+ payload: plan.payload as unknown as Record<string, unknown>,
523
+ binding: {
524
+ ownerEmail: args.ownerEmail,
525
+ sourceId: args.source.id,
526
+ changeSetId: args.changeSet.id,
527
+ executionId: existing.id,
528
+ idempotencyKey: plan.idempotencyKey,
529
+ },
530
+ });
531
+ const nextReference = builderExecutionPayloadReference(payloadJson);
532
+ const previousReference = builderExecutionPayloadReference(
533
+ existing.payloadJson,
534
+ );
535
+ try {
536
+ const updatedRows = await db.transaction(async (tx) => {
537
+ const updated = await tx
538
+ .update(schema.contentDatabaseSourceExecutions)
539
+ .set({
540
+ state: plan.state,
541
+ summary: plan.summary,
542
+ payloadJson,
543
+ lastError: plan.lastError,
544
+ updatedAt: args.now,
545
+ })
546
+ .where(
547
+ and(
548
+ eq(schema.contentDatabaseSourceExecutions.id, existing.id),
549
+ eq(
550
+ schema.contentDatabaseSourceExecutions.payloadJson,
551
+ existing.payloadJson,
552
+ ),
553
+ eq(schema.contentDatabaseSourceExecutions.state, existing.state),
554
+ existing.attemptToken
555
+ ? eq(
556
+ schema.contentDatabaseSourceExecutions.attemptToken,
557
+ existing.attemptToken,
558
+ )
559
+ : isNull(schema.contentDatabaseSourceExecutions.attemptToken),
560
+ ),
561
+ )
562
+ .returning({ id: schema.contentDatabaseSourceExecutions.id });
563
+ if (
564
+ updated[0] &&
565
+ previousReference &&
566
+ previousReference !== nextReference
567
+ ) {
568
+ await enqueueBuilderPrivatePayloadCleanup(
569
+ tx as unknown as ReturnType<typeof getDb>,
570
+ previousReference,
571
+ );
572
+ }
573
+ return updated;
574
+ });
575
+ const [updated] = updatedRows;
576
+ if (!updated)
577
+ fail("Builder execution changed during prepare.", {
578
+ errorCode: "builder_execution_changed",
579
+ statusCode: 409,
580
+ });
581
+ } catch (error) {
582
+ let currentPayloadJson: string | null | undefined;
583
+ try {
584
+ const [current] = await db
585
+ .select({
586
+ payloadJson: schema.contentDatabaseSourceExecutions.payloadJson,
587
+ })
588
+ .from(schema.contentDatabaseSourceExecutions)
589
+ .where(eq(schema.contentDatabaseSourceExecutions.id, existing.id))
590
+ .limit(1);
591
+ currentPayloadJson = current?.payloadJson;
592
+ } catch {
593
+ // Inconclusive readback retains both refs.
594
+ }
595
+ if (currentPayloadJson === payloadJson) {
596
+ // The replacement committed despite an ambiguous acknowledgement.
597
+ } else {
598
+ if (currentPayloadJson !== undefined && nextReference) {
599
+ await cleanupBuilderPrivatePayload(
600
+ nextReference,
601
+ "uncommitted prepared execution",
602
+ );
603
+ }
604
+ throw error;
605
+ }
606
+ }
607
+ if (previousReference && previousReference !== nextReference) {
608
+ await cleanupBuilderPrivatePayload(
609
+ previousReference,
610
+ "superseded prepared execution",
611
+ );
612
+ }
517
613
  } else {
614
+ let insertedReference: string | null = null;
615
+ let insertedPayloadJson: string | null = null;
518
616
  try {
617
+ const payloadJson = await storeBuilderExecutionPayload({
618
+ payload: plan.payload as unknown as Record<string, unknown>,
619
+ binding: {
620
+ ownerEmail: args.ownerEmail,
621
+ sourceId: args.source.id,
622
+ changeSetId: args.changeSet.id,
623
+ executionId,
624
+ idempotencyKey: plan.idempotencyKey,
625
+ },
626
+ });
627
+ insertedReference = builderExecutionPayloadReference(payloadJson);
628
+ insertedPayloadJson = payloadJson;
519
629
  await db.insert(schema.contentDatabaseSourceExecutions).values({
520
630
  id: executionId,
521
631
  ownerEmail: args.ownerEmail,
@@ -526,18 +636,44 @@ async function upsertExecutionGate(args: {
526
636
  state: plan.state,
527
637
  idempotencyKey: plan.idempotencyKey,
528
638
  summary: plan.summary,
529
- payloadJson: JSON.stringify(plan.payload),
639
+ payloadJson,
530
640
  lastError: plan.lastError,
531
641
  createdAt: args.now,
532
642
  updatedAt: args.now,
533
643
  });
534
644
  } catch (error) {
535
- const [winner] = await db
536
- .select()
537
- .from(schema.contentDatabaseSourceExecutions)
538
- .where(eq(schema.contentDatabaseSourceExecutions.id, executionId))
539
- .limit(1);
540
- if (!winner) throw error;
645
+ let winner:
646
+ | typeof schema.contentDatabaseSourceExecutions.$inferSelect
647
+ | null
648
+ | undefined;
649
+ let readbackCompleted = false;
650
+ try {
651
+ [winner] = await db
652
+ .select()
653
+ .from(schema.contentDatabaseSourceExecutions)
654
+ .where(eq(schema.contentDatabaseSourceExecutions.id, executionId))
655
+ .limit(1);
656
+ readbackCompleted = true;
657
+ } catch {
658
+ // Inconclusive readback retains the new ref.
659
+ }
660
+ if (!winner) {
661
+ if (readbackCompleted && insertedReference) {
662
+ await cleanupBuilderPrivatePayload(
663
+ insertedReference,
664
+ "uncommitted prepared execution",
665
+ );
666
+ }
667
+ throw error;
668
+ }
669
+ if (winner.payloadJson === insertedPayloadJson) {
670
+ // The insert committed despite an ambiguous acknowledgement.
671
+ } else if (insertedReference) {
672
+ await cleanupBuilderPrivatePayload(
673
+ insertedReference,
674
+ "uncommitted prepared execution",
675
+ );
676
+ }
541
677
  if (
542
678
  shouldPreserveBuilderExecution({
543
679
  state: winner.state,
@@ -558,8 +694,18 @@ async function upsertExecutionGate(args: {
558
694
  .where(eq(schema.contentDatabaseSourceExecutions.id, executionId));
559
695
  if (!execution) return;
560
696
 
697
+ const storedPayload = await readBuilderExecutionPayload({
698
+ payloadJson: execution.payloadJson,
699
+ binding: {
700
+ ownerEmail: execution.ownerEmail,
701
+ sourceId: execution.sourceId,
702
+ changeSetId: execution.changeSetId,
703
+ executionId: execution.id,
704
+ idempotencyKey: execution.idempotencyKey,
705
+ },
706
+ });
561
707
  const payload = validateBuilderCmsExecutionDryRun({
562
- storedPayload: parsePayload(execution.payloadJson),
708
+ storedPayload,
563
709
  plan,
564
710
  now: args.now,
565
711
  });
@@ -571,17 +717,97 @@ async function upsertExecutionGate(args: {
571
717
  ? `${plan.summary} Dry run validated blockers locally.`
572
718
  : `${plan.summary} Dry run found a stale execution gate.`;
573
719
 
574
- await db
575
- .update(schema.contentDatabaseSourceExecutions)
576
- .set({
577
- state: dryRun?.status === "stale" ? "blocked" : plan.state,
578
- summary,
579
- payloadJson: JSON.stringify(payload),
580
- lastError:
581
- dryRun?.status === "stale" ? dryRun.mismatches[0] : plan.lastError,
582
- updatedAt: args.now,
583
- })
584
- .where(eq(schema.contentDatabaseSourceExecutions.id, executionId));
720
+ const payloadJson = await storeBuilderExecutionPayload({
721
+ payload,
722
+ binding: {
723
+ ownerEmail: execution.ownerEmail,
724
+ sourceId: execution.sourceId,
725
+ changeSetId: execution.changeSetId,
726
+ executionId: execution.id,
727
+ idempotencyKey: execution.idempotencyKey,
728
+ },
729
+ });
730
+ const nextReference = builderExecutionPayloadReference(payloadJson);
731
+ const previousReference = builderExecutionPayloadReference(
732
+ execution.payloadJson,
733
+ );
734
+ try {
735
+ const updatedRows = await db.transaction(async (tx) => {
736
+ const updated = await tx
737
+ .update(schema.contentDatabaseSourceExecutions)
738
+ .set({
739
+ state: dryRun?.status === "stale" ? "blocked" : plan.state,
740
+ summary,
741
+ payloadJson,
742
+ lastError:
743
+ dryRun?.status === "stale" ? dryRun.mismatches[0] : plan.lastError,
744
+ updatedAt: args.now,
745
+ })
746
+ .where(
747
+ and(
748
+ eq(schema.contentDatabaseSourceExecutions.id, executionId),
749
+ eq(
750
+ schema.contentDatabaseSourceExecutions.payloadJson,
751
+ execution.payloadJson,
752
+ ),
753
+ eq(schema.contentDatabaseSourceExecutions.state, execution.state),
754
+ execution.attemptToken
755
+ ? eq(
756
+ schema.contentDatabaseSourceExecutions.attemptToken,
757
+ execution.attemptToken,
758
+ )
759
+ : isNull(schema.contentDatabaseSourceExecutions.attemptToken),
760
+ ),
761
+ )
762
+ .returning({ id: schema.contentDatabaseSourceExecutions.id });
763
+ if (
764
+ updated[0] &&
765
+ previousReference &&
766
+ previousReference !== nextReference
767
+ ) {
768
+ await enqueueBuilderPrivatePayloadCleanup(
769
+ tx as unknown as ReturnType<typeof getDb>,
770
+ previousReference,
771
+ );
772
+ }
773
+ return updated;
774
+ });
775
+ const [updated] = updatedRows;
776
+ if (!updated)
777
+ fail("Builder execution changed during validation.", {
778
+ errorCode: "builder_execution_changed",
779
+ statusCode: 409,
780
+ });
781
+ } catch (error) {
782
+ let currentPayloadJson: string | null | undefined;
783
+ try {
784
+ const [current] = await db
785
+ .select({
786
+ payloadJson: schema.contentDatabaseSourceExecutions.payloadJson,
787
+ })
788
+ .from(schema.contentDatabaseSourceExecutions)
789
+ .where(eq(schema.contentDatabaseSourceExecutions.id, executionId))
790
+ .limit(1);
791
+ currentPayloadJson = current?.payloadJson;
792
+ } catch {
793
+ // Inconclusive readback retains both refs.
794
+ }
795
+ if (currentPayloadJson !== payloadJson) {
796
+ if (currentPayloadJson !== undefined && nextReference) {
797
+ await cleanupBuilderPrivatePayload(
798
+ nextReference,
799
+ "uncommitted validated execution",
800
+ );
801
+ }
802
+ throw error;
803
+ }
804
+ }
805
+ if (previousReference && previousReference !== nextReference) {
806
+ await cleanupBuilderPrivatePayload(
807
+ previousReference,
808
+ "superseded validated execution",
809
+ );
810
+ }
585
811
  }
586
812
 
587
813
  export default defineAction({
@@ -0,0 +1,45 @@
1
+ import { ActionContractError } from "@agent-native/core";
2
+ import { defineAction } from "@agent-native/core/action";
3
+ import { z } from "zod";
4
+
5
+ import { LiveBodyConversionError } from "../server/live-copy/persist.js";
6
+ import {
7
+ switchPageToLegacy,
8
+ switchPageToLiveCopy,
9
+ } from "../server/live-copy/switch.js";
10
+ import { assertDocumentMutationAccess } from "./_document-mutation-access.js";
11
+
12
+ export default defineAction({
13
+ description:
14
+ "Switch one page's body writes to its live copy, or back to the legacy writer, for the live-copy rollout.",
15
+ uiOnly: true,
16
+ agentTool: false,
17
+ toolCallable: false,
18
+ mcpTool: false,
19
+ schema: z.object({
20
+ id: z.string().min(1).describe("Document ID"),
21
+ switched: z
22
+ .boolean()
23
+ .describe("true writes through the live copy; false goes back"),
24
+ }),
25
+ run: async (args, ctx) => {
26
+ await assertDocumentMutationAccess(args.id, "editor", "id");
27
+ try {
28
+ const gate = args.switched
29
+ ? await switchPageToLiveCopy(args.id, { ctx })
30
+ : await switchPageToLegacy(args.id);
31
+ return { id: args.id, ...gate };
32
+ } catch (error) {
33
+ if (error instanceof LiveBodyConversionError) {
34
+ throw new ActionContractError(error.message, {
35
+ errorCode:
36
+ error.code === "flag_off"
37
+ ? "LIVE_BODY_WRITER_OFF"
38
+ : "LIVE_BODY_SWITCH_CONFLICT",
39
+ statusCode: 409,
40
+ });
41
+ }
42
+ throw error;
43
+ }
44
+ },
45
+ });