@growth-labs/cms 0.8.2 → 0.8.3

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 (77) hide show
  1. package/dist/engine/ai-dek-notifications.d.ts +2 -0
  2. package/dist/engine/ai-dek-notifications.d.ts.map +1 -1
  3. package/dist/engine/ai-dek-notifications.js +11 -2
  4. package/dist/engine/ai-dek-notifications.js.map +1 -1
  5. package/dist/engine/ai-dek.d.ts +23 -0
  6. package/dist/engine/ai-dek.d.ts.map +1 -1
  7. package/dist/engine/ai-dek.js +94 -31
  8. package/dist/engine/ai-dek.js.map +1 -1
  9. package/dist/engine/foundry-dispatch.d.ts +13 -3
  10. package/dist/engine/foundry-dispatch.d.ts.map +1 -1
  11. package/dist/engine/foundry-dispatch.js +80 -25
  12. package/dist/engine/foundry-dispatch.js.map +1 -1
  13. package/dist/engine/fronts-publish-intent.d.ts +55 -0
  14. package/dist/engine/fronts-publish-intent.d.ts.map +1 -1
  15. package/dist/engine/fronts-publish-intent.js +181 -55
  16. package/dist/engine/fronts-publish-intent.js.map +1 -1
  17. package/dist/engine/fronts-publish.d.ts +11 -0
  18. package/dist/engine/fronts-publish.d.ts.map +1 -1
  19. package/dist/engine/fronts-publish.js +106 -4
  20. package/dist/engine/fronts-publish.js.map +1 -1
  21. package/dist/engine/index.d.ts +1 -1
  22. package/dist/engine/index.d.ts.map +1 -1
  23. package/dist/engine/index.js +1 -1
  24. package/dist/engine/index.js.map +1 -1
  25. package/dist/engine/podcast-source.d.ts +3 -2
  26. package/dist/engine/podcast-source.d.ts.map +1 -1
  27. package/dist/engine/podcast-source.js +6 -4
  28. package/dist/engine/podcast-source.js.map +1 -1
  29. package/dist/engine/publisher.d.ts.map +1 -1
  30. package/dist/engine/publisher.js +16 -8
  31. package/dist/engine/publisher.js.map +1 -1
  32. package/dist/engine/soft-delete.d.ts +3 -1
  33. package/dist/engine/soft-delete.d.ts.map +1 -1
  34. package/dist/engine/soft-delete.js +21 -5
  35. package/dist/engine/soft-delete.js.map +1 -1
  36. package/dist/engine/video-source.d.ts +4 -2
  37. package/dist/engine/video-source.d.ts.map +1 -1
  38. package/dist/engine/video-source.js +7 -4
  39. package/dist/engine/video-source.js.map +1 -1
  40. package/dist/routes/content.d.ts +14 -0
  41. package/dist/routes/content.d.ts.map +1 -1
  42. package/dist/routes/content.js +37 -24
  43. package/dist/routes/content.js.map +1 -1
  44. package/dist/routes/context.d.ts +6 -0
  45. package/dist/routes/context.d.ts.map +1 -1
  46. package/dist/routes/context.js.map +1 -1
  47. package/dist/routes/fronts-publish.d.ts +2 -0
  48. package/dist/routes/fronts-publish.d.ts.map +1 -1
  49. package/dist/routes/fronts-publish.js +178 -2
  50. package/dist/routes/fronts-publish.js.map +1 -1
  51. package/dist/ui/api/_content-config.d.ts.map +1 -1
  52. package/dist/ui/api/_content-config.js +7 -0
  53. package/dist/ui/api/_content-config.js.map +1 -1
  54. package/dist/ui/api/fronts/schedule.d.ts +2 -0
  55. package/dist/ui/api/fronts/schedule.d.ts.map +1 -1
  56. package/dist/ui/api/fronts/schedule.js +2 -0
  57. package/dist/ui/api/fronts/schedule.js.map +1 -1
  58. package/dist/ui/editor/ContentForm.d.ts.map +1 -1
  59. package/dist/ui/editor/ContentForm.js +2 -2
  60. package/dist/ui/editor/ContentForm.js.map +1 -1
  61. package/package.json +1 -1
  62. package/src/engine/ai-dek-notifications.ts +19 -2
  63. package/src/engine/ai-dek.ts +131 -40
  64. package/src/engine/foundry-dispatch.ts +110 -24
  65. package/src/engine/fronts-publish-intent.ts +289 -65
  66. package/src/engine/fronts-publish.ts +121 -4
  67. package/src/engine/index.ts +1 -0
  68. package/src/engine/podcast-source.ts +6 -4
  69. package/src/engine/publisher.ts +20 -8
  70. package/src/engine/soft-delete.ts +28 -8
  71. package/src/engine/video-source.ts +7 -4
  72. package/src/routes/content.ts +56 -31
  73. package/src/routes/context.ts +6 -0
  74. package/src/routes/fronts-publish.ts +213 -2
  75. package/src/ui/api/_content-config.ts +15 -0
  76. package/src/ui/api/fronts/schedule.ts +4 -0
  77. package/src/ui/editor/ContentForm.tsx +4 -5
@@ -304,15 +304,53 @@ export function parseFrontsChannelTargets(
304
304
  return normalizeChannelTargets(parsed)
305
305
  }
306
306
 
307
+ /** Host defaults are opt-in; an invalid configured list must fail before a save. */
308
+ export function parseFrontsDefaultChannelTargets(
309
+ value: unknown,
310
+ ):
311
+ | { targets: string[] }
312
+ | { error: 'missing_fronts_default_channel_targets' | 'invalid_fronts_default_channel_targets' } {
313
+ if (value === undefined) return { error: 'missing_fronts_default_channel_targets' }
314
+ if (!Array.isArray(value)) return { error: 'invalid_fronts_default_channel_targets' }
315
+ const parsed = normalizeChannelTargets(value)
316
+ return 'code' in parsed ? { error: 'invalid_fronts_default_channel_targets' } : parsed
317
+ }
318
+
319
+ interface ScheduledTargetsSnapshot {
320
+ id: string
321
+ canonical_version: number | null
322
+ publish_at: number
323
+ updated_at: number | null
324
+ fronts_publish_trigger_token: string | null
325
+ fronts_publish_status: string | null
326
+ }
327
+
328
+ interface ChannelTargetsWriteOptions {
329
+ onlyIfNull?: boolean
330
+ expectedScheduled?: ScheduledTargetsSnapshot
331
+ }
332
+
307
333
  /**
308
334
  * Arm an item's publish targets. Rejects an empty or non-string list loudly
309
335
  * instead of storing something that would later hydrate as "complete".
310
336
  */
337
+ export function setFrontsChannelTargets(
338
+ db: D1Database,
339
+ contentId: string,
340
+ targets: readonly string[],
341
+ ): Promise<void>
342
+ export function setFrontsChannelTargets(
343
+ db: D1Database,
344
+ contentId: string,
345
+ targets: readonly string[],
346
+ options: ChannelTargetsWriteOptions,
347
+ ): Promise<boolean>
311
348
  export async function setFrontsChannelTargets(
312
349
  db: D1Database,
313
350
  contentId: string,
314
351
  targets: readonly string[],
315
- ): Promise<void> {
352
+ options?: ChannelTargetsWriteOptions,
353
+ ): Promise<void | boolean> {
316
354
  const normalized = normalizeChannelTargets(targets)
317
355
  if ('code' in normalized) {
318
356
  throw new FrontsPublishIntentError(
@@ -321,10 +359,83 @@ export async function setFrontsChannelTargets(
321
359
  'targets must be a non-empty list of non-empty strings',
322
360
  )
323
361
  }
324
- await db
325
- .prepare('UPDATE content_items SET fronts_channel_targets = ? WHERE id = ?')
326
- .bind(JSON.stringify(normalized.targets), contentId)
362
+ const predicates = ['id = ?']
363
+ const values: unknown[] = [JSON.stringify(normalized.targets), contentId]
364
+ if (options?.onlyIfNull || options?.expectedScheduled) {
365
+ predicates.push('fronts_channel_targets IS NULL', 'deleted_at IS NULL')
366
+ }
367
+ if (options?.expectedScheduled) {
368
+ const expected = options.expectedScheduled
369
+ if (expected.id !== contentId)
370
+ throw new Error('Channel target snapshot belongs to different content')
371
+ predicates.push(
372
+ "status = 'scheduled'",
373
+ 'canonical_version IS ?',
374
+ 'publish_at IS ?',
375
+ 'updated_at IS ?',
376
+ 'fronts_publish_trigger_token IS ?',
377
+ 'fronts_publish_status IS ?',
378
+ )
379
+ values.push(
380
+ expected.canonical_version,
381
+ expected.publish_at,
382
+ expected.updated_at,
383
+ expected.fronts_publish_trigger_token,
384
+ expected.fronts_publish_status,
385
+ )
386
+ }
387
+ const result = await db
388
+ .prepare(
389
+ `UPDATE content_items SET fronts_channel_targets = ? WHERE ${predicates.join(' AND ')}`,
390
+ )
391
+ .bind(...values)
327
392
  .run()
393
+ // Preserve the original explicit setter contract. CAS callers need a proved
394
+ // change count, including an honest false when another writer won.
395
+ if (!options) return
396
+ const changes = result.meta?.changes
397
+ if (
398
+ result.success === false ||
399
+ typeof changes !== 'number' ||
400
+ !Number.isInteger(changes) ||
401
+ changes < 0 ||
402
+ changes > 1
403
+ ) {
404
+ throw new Error('Fronts channel target write did not report a valid change count')
405
+ }
406
+ return changes === 1
407
+ }
408
+
409
+ /** A bounded repair requested by Go; all rows keep their current publish/media identity. */
410
+ export async function backfillFrontsChannelTargets(
411
+ db: D1Database,
412
+ targets: readonly string[],
413
+ limit: number,
414
+ ): Promise<{ examined: number; armed: number; skipped: number; contentIds: string[] }> {
415
+ if (!Number.isInteger(limit) || limit < 1 || limit > 50)
416
+ throw new Error('Invalid channel target backfill limit')
417
+ const selected = await db
418
+ .prepare(`SELECT id, canonical_version, publish_at, updated_at,
419
+ fronts_publish_trigger_token, fronts_publish_status
420
+ FROM content_items
421
+ WHERE status = 'scheduled' AND deleted_at IS NULL
422
+ AND publish_at IS NOT NULL AND fronts_channel_targets IS NULL
423
+ ORDER BY publish_at ASC, id ASC LIMIT ?`)
424
+ .bind(limit)
425
+ .all<ScheduledTargetsSnapshot>()
426
+ if (selected.success === false || !Array.isArray(selected.results))
427
+ throw new Error('Fronts channel target selection failed')
428
+ const contentIds: string[] = []
429
+ for (const row of selected.results) {
430
+ if (await setFrontsChannelTargets(db, row.id, targets, { expectedScheduled: row }))
431
+ contentIds.push(row.id)
432
+ }
433
+ return {
434
+ examined: selected.results.length,
435
+ armed: contentIds.length,
436
+ skipped: selected.results.length - contentIds.length,
437
+ contentIds,
438
+ }
328
439
  }
329
440
 
330
441
  // --- hydration -------------------------------------------------------------
@@ -617,6 +728,176 @@ async function loadContentRefSources(
617
728
  return { articles, videos, podcasts, media }
618
729
  }
619
730
 
731
+ /** Preparation and publication projections share the same source validation. */
732
+ async function videoSourceRef(
733
+ row: IntentCandidateRow,
734
+ found: VideoRefRow | undefined,
735
+ ): Promise<FrontsPublishVideoSourceRef | null> {
736
+ const correlation = found?.processing_correlation_id?.trim()
737
+ if (
738
+ !found ||
739
+ !validPodcastSourceUrl(found.processing_source_url) ||
740
+ !videoSourceIngestableOverHttps(found.processing_source_kind) ||
741
+ !correlation ||
742
+ !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(correlation)
743
+ )
744
+ return null
745
+ if (
746
+ typeof found.duration_seconds !== 'number' ||
747
+ !Number.isFinite(found.duration_seconds) ||
748
+ found.duration_seconds <= 0
749
+ ) {
750
+ throw new FrontsPublishIntentError(
751
+ 'video_source_duration_unavailable',
752
+ row.id,
753
+ 'Video source duration is not verified.',
754
+ )
755
+ }
756
+ return {
757
+ kind: 'https',
758
+ url: found.processing_source_url,
759
+ duration_seconds: found.duration_seconds,
760
+ generation: await frontsVideoSourceGeneration({
761
+ content_id: row.id,
762
+ slug: row.slug,
763
+ canonical_version: row.canonical_version as number,
764
+ processing_source_url: found.processing_source_url,
765
+ processing_source_kind: found.processing_source_kind,
766
+ processing_trigger_token: found.processing_trigger_token,
767
+ }),
768
+ correlation_id: correlation,
769
+ visibility: row.visibility === 'premium' ? 'premium' : 'free',
770
+ }
771
+ }
772
+
773
+ async function podcastSourceRef(
774
+ row: IntentCandidateRow,
775
+ found: PodcastRefRow | undefined,
776
+ ): Promise<FrontsPublishPodcastSourceRef | null> {
777
+ if (
778
+ !found ||
779
+ !validPodcastSourceUrl(found.processing_source_url) ||
780
+ found.processing_source_kind !== 'https'
781
+ )
782
+ return null
783
+ if (
784
+ typeof found.duration_seconds !== 'number' ||
785
+ !Number.isFinite(found.duration_seconds) ||
786
+ found.duration_seconds <= 0
787
+ ) {
788
+ throw new FrontsPublishIntentError(
789
+ 'podcast_source_duration_unavailable',
790
+ row.id,
791
+ 'Podcast source duration is not verified.',
792
+ )
793
+ }
794
+ return {
795
+ kind: 'https',
796
+ url: found.processing_source_url,
797
+ duration_seconds: found.duration_seconds,
798
+ generation: await frontsPodcastSourceGeneration({
799
+ content_id: row.id,
800
+ slug: row.slug,
801
+ canonical_version: row.canonical_version as number,
802
+ processing_source_url: found.processing_source_url,
803
+ processing_source_kind: found.processing_source_kind,
804
+ processing_trigger_token: found.processing_trigger_token,
805
+ }),
806
+ }
807
+ }
808
+
809
+ /** A captured source is preparation authority, never publication authority. */
810
+ export interface FrontsMediaPreparation {
811
+ content_id: string
812
+ type: 'video' | 'podcast'
813
+ slug: string
814
+ channel: string | null
815
+ canonical_version: number
816
+ /** The actual slot, if any; never invented for a draft. */
817
+ publish_at: number | null
818
+ /** Actual capture time; unknown for a legacy token. */
819
+ captured_at: number | null
820
+ video_source: FrontsPublishVideoSourceRef | null
821
+ podcast_source: FrontsPublishPodcastSourceRef | null
822
+ }
823
+
824
+ export interface FrontsMediaPreparations {
825
+ version: 1
826
+ items: FrontsMediaPreparation[]
827
+ incomplete: Array<{ content_id: string; code: string; message: string }>
828
+ }
829
+
830
+ /**
831
+ * Extend the existing authenticated poll with captured media, independently of
832
+ * publish_at/status=scheduled. No publish token, fake deadline or new queue is
833
+ * minted by this read. A finite complete response fails loudly on overflow.
834
+ */
835
+ export async function hydrateFrontsMediaPreparations(
836
+ db: D1Database,
837
+ options: Pick<
838
+ HydrateFrontsPublishIntentsOptions,
839
+ 'limit' | 'videoSourceProductionEnabled' | 'podcastSourceProductionEnabled'
840
+ > = {},
841
+ ): Promise<FrontsMediaPreparations> {
842
+ const limit = Math.min(Math.max(1, Math.floor(options.limit ?? 200)), 1000)
843
+ const result: FrontsMediaPreparations = { version: 1, items: [], incomplete: [] }
844
+ const candidates = await db
845
+ .prepare(`SELECT ${CANDIDATE_COLUMNS} FROM content_items
846
+ WHERE deleted_at IS NULL AND status IN ('draft', 'review', 'scheduled', 'published')
847
+ AND ((type = 'video' AND ? = 1 AND EXISTS (
848
+ SELECT 1 FROM video_content v WHERE v.content_id = content_items.id
849
+ AND v.processing_trigger_token IS NOT NULL
850
+ AND (v.hls_ready <> 1 OR v.hls_manifest_url IS NULL OR v.transcript_ready <> 1)))
851
+ OR (type = 'podcast' AND ? = 1 AND EXISTS (
852
+ SELECT 1 FROM podcast_content p WHERE p.content_id = content_items.id
853
+ AND p.processing_trigger_token IS NOT NULL
854
+ AND (trim(COALESCE(p.audio_r2_key, '')) = '' OR trim(COALESCE(p.transcript, '')) = ''))))
855
+ ORDER BY created_at ASC, id ASC LIMIT ?`)
856
+ .bind(
857
+ options.videoSourceProductionEnabled === false ? 0 : 1,
858
+ options.podcastSourceProductionEnabled === false ? 0 : 1,
859
+ limit + 1,
860
+ )
861
+ .all<IntentCandidateRow>()
862
+ const rows = candidates.results ?? []
863
+ if (rows.length > limit) throw new FrontsScheduleOverflowError(limit)
864
+ const refs = await loadContentRefSources(db, rows)
865
+ for (const row of rows) {
866
+ try {
867
+ if (!Number.isInteger(row.canonical_version) || (row.canonical_version ?? 0) <= 0) {
868
+ throw new FrontsPublishIntentError('invalid_canonical_version', row.id)
869
+ }
870
+ const video = row.type === 'video' ? await videoSourceRef(row, refs.videos.get(row.id)) : null
871
+ const podcast =
872
+ row.type === 'podcast' ? await podcastSourceRef(row, refs.podcasts.get(row.id)) : null
873
+ if (!video && !podcast)
874
+ throw new FrontsPublishIntentError(
875
+ row.type === 'video' ? 'video_media_not_ready' : 'missing_podcast_audio',
876
+ row.id,
877
+ 'Captured source is incomplete or not ingestable.',
878
+ )
879
+ const token = (row.type === 'video' ? refs.videos.get(row.id) : refs.podcasts.get(row.id))
880
+ ?.processing_trigger_token
881
+ const capturedAt = /^fronts-source-v2:([1-9][0-9]*):[0-9a-f-]{36}$/.exec(token ?? '')?.[1]
882
+ result.items.push({
883
+ content_id: row.id,
884
+ type: row.type as 'video' | 'podcast',
885
+ slug: row.slug,
886
+ channel: row.channel,
887
+ canonical_version: row.canonical_version as number,
888
+ publish_at: row.publish_at,
889
+ captured_at: capturedAt ? Number(capturedAt) : null,
890
+ video_source: video,
891
+ podcast_source: podcast,
892
+ })
893
+ } catch (error) {
894
+ if (!(error instanceof FrontsPublishIntentError)) throw error
895
+ result.incomplete.push({ content_id: row.id, code: error.code, message: error.message })
896
+ }
897
+ }
898
+ return result
899
+ }
900
+
620
901
  /** Build one intent, throwing FrontsPublishIntentError on any missing required value. */
621
902
  async function buildIntent(
622
903
  row: IntentCandidateRow,
@@ -739,41 +1020,8 @@ async function buildContentRefs(
739
1020
  message:
740
1021
  'a video intent requires video_content.hls_ready = 1 and a non-empty hls_manifest_url',
741
1022
  }
742
- const sourceUrl = found?.processing_source_url
743
- const correlationId = found?.processing_correlation_id?.trim()
744
- if (
745
- found &&
746
- validPodcastSourceUrl(sourceUrl) &&
747
- videoSourceIngestableOverHttps(found.processing_source_kind) &&
748
- correlationId &&
749
- /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(correlationId)
750
- ) {
751
- if (
752
- typeof found.duration_seconds !== 'number' ||
753
- !Number.isFinite(found.duration_seconds) ||
754
- found.duration_seconds <= 0
755
- ) {
756
- throw new FrontsPublishIntentError(
757
- 'video_source_duration_unavailable',
758
- row.id,
759
- 'Video source duration is not verified.',
760
- )
761
- }
762
- videoSource = {
763
- kind: 'https',
764
- url: sourceUrl,
765
- duration_seconds: found.duration_seconds,
766
- generation: await frontsVideoSourceGeneration({
767
- content_id: row.id,
768
- slug: row.slug,
769
- canonical_version: row.canonical_version as number,
770
- processing_source_url: found.processing_source_url,
771
- processing_source_kind: found.processing_source_kind,
772
- processing_trigger_token: found.processing_trigger_token,
773
- }),
774
- correlation_id: correlationId,
775
- visibility: row.visibility === 'premium' ? 'premium' : 'free',
776
- }
1023
+ videoSource = await videoSourceRef(row, found)
1024
+ if (videoSource) {
777
1025
  incomplete = {
778
1026
  content_id: row.id,
779
1027
  code: failure.code,
@@ -824,32 +1072,8 @@ async function buildContentRefs(
824
1072
  code: 'missing_podcast_audio' as const,
825
1073
  message: 'Podcast audio is not ready.',
826
1074
  }
827
- const sourceUrl = found?.processing_source_url
828
- if (found && validPodcastSourceUrl(sourceUrl) && found.processing_source_kind === 'https') {
829
- if (
830
- typeof found.duration_seconds !== 'number' ||
831
- !Number.isFinite(found.duration_seconds) ||
832
- found.duration_seconds <= 0
833
- ) {
834
- throw new FrontsPublishIntentError(
835
- 'podcast_source_duration_unavailable',
836
- row.id,
837
- 'Podcast source duration is not verified.',
838
- )
839
- }
840
- podcastSource = {
841
- kind: 'https',
842
- url: sourceUrl,
843
- duration_seconds: found.duration_seconds,
844
- generation: await frontsPodcastSourceGeneration({
845
- content_id: row.id,
846
- slug: row.slug,
847
- canonical_version: row.canonical_version as number,
848
- processing_source_url: found.processing_source_url,
849
- processing_source_kind: found.processing_source_kind,
850
- processing_trigger_token: found.processing_trigger_token,
851
- }),
852
- }
1075
+ podcastSource = await podcastSourceRef(row, found)
1076
+ if (podcastSource) {
853
1077
  incomplete = {
854
1078
  content_id: row.id,
855
1079
  code: failure.code,
@@ -24,7 +24,14 @@
24
24
  // additive and gated by FRONTS_PUBLISH_ENABLED (default ON — a rollback lever,
25
25
  // not a dark-staging gate).
26
26
 
27
+ import type { LlmDispatch } from '../providers/types.js'
27
28
  import type { ContentType, FrontsPublishStatus } from '../schema/types.js'
29
+ import {
30
+ applyGeneratedContentDek,
31
+ generateContentDekSuggestion,
32
+ loadDekGenerationContextFromSnapshot,
33
+ } from './ai-dek.js'
34
+ import { parseLockedFields } from './ai-writeback.js'
28
35
  import type { D1Database } from './d1.js'
29
36
  import { FrontsScheduleOverflowError } from './fronts-publish-intent.js'
30
37
  import { podcastPublishBlocker } from './podcast-source.js'
@@ -35,6 +42,7 @@ import {
35
42
  snapshotRecord,
36
43
  } from './publication-current-guard.js'
37
44
  import { getContentSnapshot } from './publisher.js'
45
+ import { getRevision } from './revisions.js'
38
46
 
39
47
  /** The five lifecycle states foundryd reports for a Fronts publish intent. */
40
48
  export const FRONTS_PUBLISH_STATUSES: readonly FrontsPublishStatus[] = [
@@ -161,6 +169,8 @@ export interface FrontsPublishCallbackInput {
161
169
  now?: number
162
170
  /** Optional occurrence evidence carried by current publish workers. */
163
171
  publishAt?: number | null
172
+ /** Optional existing CMS LLM hook for best-effort missing-dek generation. */
173
+ llm?: LlmDispatch
164
174
  /** The route supplies the same host hook used by the editorial publish action. */
165
175
  checkReadiness?: (item: {
166
176
  id: string
@@ -183,6 +193,10 @@ export interface FrontsPublishCallbackResult {
183
193
  ledgerError: string | null
184
194
  /** A retryable hold or stale/cancelled snapshot; never consumes event_id. */
185
195
  rejection?: { code: string; status: 404 | 409 | 503; blockers?: string[]; message: string }
196
+ /** Safe post-commit reason for a missing-dek editor notification. */
197
+ dekFailureReason?: 'ai_generation_unavailable' | 'ai_generation_timeout' | null
198
+ /** Existing lifecycle payload fields, present only for this call's committed publication. */
199
+ publishedItem?: { id: string; type: ContentType; title: string } | null
186
200
  }
187
201
 
188
202
  function hasReceipt(receipt: FrontsPublishReceipt | null | undefined): boolean {
@@ -210,6 +224,8 @@ function resolveOccurredAtSeconds(
210
224
  return Math.min(seconds, now)
211
225
  }
212
226
 
227
+ class DekGenerationTimeoutError extends Error {}
228
+
213
229
  /**
214
230
  * Apply one foundryd publish-callback. Idempotent by `event_id`: a repeat
215
231
  * delivery returns the stored outcome without re-applying. See the module
@@ -250,6 +266,8 @@ export async function applyFrontsPublishCallback(
250
266
  let published = item.status === 'published'
251
267
  let attemptId: string | null = null
252
268
  let ledgerError: string | null = null
269
+ let committedDekFailureReason: FrontsPublishCallbackResult['dekFailureReason'] = null
270
+ let committedPublishedItem: FrontsPublishCallbackResult['publishedItem'] = null
253
271
  const shouldPublish = input.status === 'live' && hasReceipt(input.receipt)
254
272
  if (shouldPublish && item.status === 'published') {
255
273
  const current = await getContentSnapshot(db, input.contentId)
@@ -326,9 +344,88 @@ export async function applyFrontsPublishCallback(
326
344
  ['publish_not_due'],
327
345
  )
328
346
  }
347
+ let dekFailureReason: FrontsPublishCallbackResult['dekFailureReason'] = null
348
+ const initialExcerpt =
349
+ typeof expectedItem.excerpt === 'string' ? expectedItem.excerpt.trim() : ''
350
+ const initialLocks = parseLockedFields(
351
+ typeof expectedItem.ai_locked_fields === 'string' ? expectedItem.ai_locked_fields : null,
352
+ )
353
+ if (!initialExcerpt && !initialLocks.includes('excerpt')) {
354
+ if (!input.llm) {
355
+ dekFailureReason = 'ai_generation_unavailable'
356
+ } else {
357
+ const deadlineAt = performance.now() + 8_000
358
+ let timeout: ReturnType<typeof setTimeout> | undefined
359
+ try {
360
+ const generated = await Promise.race([
361
+ (async () => {
362
+ const context = await loadDekGenerationContextFromSnapshot(db, expectedSnapshot)
363
+ if (!context) {
364
+ return {
365
+ context: null,
366
+ suggestion: {
367
+ status: 'unavailable' as const,
368
+ dek: null,
369
+ reason: 'unavailable',
370
+ },
371
+ }
372
+ }
373
+ return {
374
+ context,
375
+ suggestion: await generateContentDekSuggestion({
376
+ context,
377
+ llm: input.llm!,
378
+ }),
379
+ }
380
+ })(),
381
+ new Promise<never>((_resolve, rejectTimeout) => {
382
+ timeout = setTimeout(() => rejectTimeout(new DekGenerationTimeoutError()), 8_000)
383
+ }),
384
+ ])
385
+ if (generated.suggestion.status !== 'generated') {
386
+ dekFailureReason = 'ai_generation_unavailable'
387
+ } else if (!generated.context || performance.now() >= deadlineAt) {
388
+ dekFailureReason = 'ai_generation_timeout'
389
+ } else {
390
+ const applied = await applyGeneratedContentDek({
391
+ db,
392
+ contentId: input.contentId,
393
+ dek: generated.suggestion.dek,
394
+ context: generated.context,
395
+ expectedPublicationSnapshot: expectedSnapshot,
396
+ })
397
+ if (applied.status === 'unavailable') dekFailureReason = 'ai_generation_unavailable'
398
+ }
399
+ } catch (error) {
400
+ dekFailureReason =
401
+ error instanceof DekGenerationTimeoutError
402
+ ? 'ai_generation_timeout'
403
+ : 'ai_generation_unavailable'
404
+ } finally {
405
+ if (timeout !== undefined) clearTimeout(timeout)
406
+ }
407
+ }
408
+ }
409
+
410
+ const currentSnapshot = await getContentSnapshot(db, input.contentId)
411
+ try {
412
+ assertCurrentPublicationSnapshot(currentSnapshot ?? {}, expectedSnapshot)
413
+ } catch {
414
+ return reject(
415
+ 'publish_snapshot_changed',
416
+ 409,
417
+ 'Scheduled content or media changed before publication.',
418
+ )
419
+ }
420
+ const currentItem = snapshotRecord(currentSnapshot?.item)
421
+ const currentExcerpt = typeof currentItem.excerpt === 'string' ? currentItem.excerpt.trim() : ''
422
+ const currentLocks = parseLockedFields(
423
+ typeof currentItem.ai_locked_fields === 'string' ? currentItem.ai_locked_fields : null,
424
+ )
425
+ if (currentExcerpt || currentLocks.includes('excerpt')) dekFailureReason = null
329
426
  const blocker =
330
- expectedItem.type === 'podcast'
331
- ? podcastPublishBlocker(snapshotRecord(expectedSnapshot.content))
427
+ currentItem.type === 'podcast'
428
+ ? podcastPublishBlocker(snapshotRecord(currentSnapshot?.content))
332
429
  : null
333
430
  if (blocker) return reject('publish_not_ready', 503, blocker.message, [blocker.code])
334
431
  if (input.checkReadiness) {
@@ -337,7 +434,7 @@ export async function applyFrontsPublishCallback(
337
434
  const readiness = await Promise.race([
338
435
  input.checkReadiness({
339
436
  id: input.contentId,
340
- type: expectedItem.type as ContentType,
437
+ type: currentItem.type as ContentType,
341
438
  status: 'scheduled',
342
439
  }),
343
440
  new Promise<never>((_resolve, rejectTimeout) => {
@@ -378,11 +475,29 @@ export async function applyFrontsPublishCallback(
378
475
  ...(publishedAt !== null ? { publishedAt } : {}),
379
476
  createdBy: 'foundry.fronts-publish',
380
477
  surfaces: [surface],
381
- expectedCurrentSnapshot: expectedSnapshot,
478
+ expectedCurrentSnapshot: currentSnapshot!,
382
479
  expectedReceiptIdentity: receiptIdentity,
383
480
  })
384
481
  attemptId = receipt.attemptId
385
482
  published = true
483
+ const committedRevision = await getRevision(db, input.contentId, receipt.revisionId)
484
+ if (attemptId && committedRevision) {
485
+ const committedItem = snapshotRecord(committedRevision.payload.item)
486
+ const committedExcerpt =
487
+ typeof committedItem.excerpt === 'string' ? committedItem.excerpt.trim() : ''
488
+ const committedLocks = parseLockedFields(
489
+ typeof committedItem.ai_locked_fields === 'string'
490
+ ? committedItem.ai_locked_fields
491
+ : null,
492
+ )
493
+ committedDekFailureReason =
494
+ committedExcerpt || committedLocks.includes('excerpt') ? null : dekFailureReason
495
+ committedPublishedItem = {
496
+ id: String(committedItem.id ?? input.contentId),
497
+ type: committedItem.type as ContentType,
498
+ title: String(committedItem.title ?? ''),
499
+ }
500
+ }
386
501
  } catch (error) {
387
502
  // A concurrently published row may belong to a newer editor/source
388
503
  // revision. Only a matching committed snapshot and full receipt prove
@@ -496,6 +611,8 @@ export async function applyFrontsPublishCallback(
496
611
  attemptId,
497
612
  contentFound: true,
498
613
  ledgerError,
614
+ dekFailureReason: committedDekFailureReason,
615
+ publishedItem: committedPublishedItem,
499
616
  }
500
617
  }
501
618
 
@@ -78,6 +78,7 @@ export type { D1Database, D1DatabaseSession, D1PreparedStatement, D1Result } fro
78
78
  export {
79
79
  applyFoundryCallback,
80
80
  type CallbackInput,
81
+ createFoundrySourceCaptureToken,
81
82
  type DispatchResult,
82
83
  dispatchToFoundry,
83
84
  type ProcessingState,
@@ -9,19 +9,21 @@ export interface FrontsPodcastSourceSnapshot {
9
9
  }
10
10
 
11
11
  /**
12
- * Stable over polling, machine media callbacks and rescheduling. Canonical
13
- * editor/source changes invalidate the old production. Neither the raw source
12
+ * V1 captures keep their canonical-version binding. New v2 captures remain
13
+ * stable over editorial metadata edits, polling, callbacks and rescheduling;
14
+ * source/slug/token changes invalidate production. Neither the raw source
14
15
  * token nor URL needs to appear in callback/notification identities.
15
16
  */
16
17
  export async function frontsPodcastSourceGeneration(
17
18
  input: FrontsPodcastSourceSnapshot,
18
19
  ): Promise<string> {
20
+ const version = input.processing_trigger_token?.startsWith('fronts-source-v2:') ? 2 : 1
19
21
  const bytes = new TextEncoder().encode(
20
22
  JSON.stringify([
21
- 'fronts-podcast-source.v1',
23
+ `fronts-podcast-source.v${version}`,
22
24
  input.content_id,
23
25
  input.slug,
24
- input.canonical_version,
26
+ ...(version === 1 ? [input.canonical_version] : []),
25
27
  input.processing_source_url,
26
28
  input.processing_source_kind,
27
29
  input.processing_trigger_token,