@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.
- package/dist/engine/ai-dek-notifications.d.ts +2 -0
- package/dist/engine/ai-dek-notifications.d.ts.map +1 -1
- package/dist/engine/ai-dek-notifications.js +11 -2
- package/dist/engine/ai-dek-notifications.js.map +1 -1
- package/dist/engine/ai-dek.d.ts +23 -0
- package/dist/engine/ai-dek.d.ts.map +1 -1
- package/dist/engine/ai-dek.js +94 -31
- package/dist/engine/ai-dek.js.map +1 -1
- package/dist/engine/foundry-dispatch.d.ts +13 -3
- package/dist/engine/foundry-dispatch.d.ts.map +1 -1
- package/dist/engine/foundry-dispatch.js +80 -25
- package/dist/engine/foundry-dispatch.js.map +1 -1
- package/dist/engine/fronts-publish-intent.d.ts +55 -0
- package/dist/engine/fronts-publish-intent.d.ts.map +1 -1
- package/dist/engine/fronts-publish-intent.js +181 -55
- package/dist/engine/fronts-publish-intent.js.map +1 -1
- package/dist/engine/fronts-publish.d.ts +11 -0
- package/dist/engine/fronts-publish.d.ts.map +1 -1
- package/dist/engine/fronts-publish.js +106 -4
- package/dist/engine/fronts-publish.js.map +1 -1
- package/dist/engine/index.d.ts +1 -1
- package/dist/engine/index.d.ts.map +1 -1
- package/dist/engine/index.js +1 -1
- package/dist/engine/index.js.map +1 -1
- package/dist/engine/podcast-source.d.ts +3 -2
- package/dist/engine/podcast-source.d.ts.map +1 -1
- package/dist/engine/podcast-source.js +6 -4
- package/dist/engine/podcast-source.js.map +1 -1
- package/dist/engine/publisher.d.ts.map +1 -1
- package/dist/engine/publisher.js +16 -8
- package/dist/engine/publisher.js.map +1 -1
- package/dist/engine/soft-delete.d.ts +3 -1
- package/dist/engine/soft-delete.d.ts.map +1 -1
- package/dist/engine/soft-delete.js +21 -5
- package/dist/engine/soft-delete.js.map +1 -1
- package/dist/engine/video-source.d.ts +4 -2
- package/dist/engine/video-source.d.ts.map +1 -1
- package/dist/engine/video-source.js +7 -4
- package/dist/engine/video-source.js.map +1 -1
- package/dist/routes/content.d.ts +14 -0
- package/dist/routes/content.d.ts.map +1 -1
- package/dist/routes/content.js +37 -24
- package/dist/routes/content.js.map +1 -1
- package/dist/routes/context.d.ts +6 -0
- package/dist/routes/context.d.ts.map +1 -1
- package/dist/routes/context.js.map +1 -1
- package/dist/routes/fronts-publish.d.ts +2 -0
- package/dist/routes/fronts-publish.d.ts.map +1 -1
- package/dist/routes/fronts-publish.js +178 -2
- package/dist/routes/fronts-publish.js.map +1 -1
- package/dist/ui/api/_content-config.d.ts.map +1 -1
- package/dist/ui/api/_content-config.js +7 -0
- package/dist/ui/api/_content-config.js.map +1 -1
- package/dist/ui/api/fronts/schedule.d.ts +2 -0
- package/dist/ui/api/fronts/schedule.d.ts.map +1 -1
- package/dist/ui/api/fronts/schedule.js +2 -0
- package/dist/ui/api/fronts/schedule.js.map +1 -1
- package/dist/ui/editor/ContentForm.d.ts.map +1 -1
- package/dist/ui/editor/ContentForm.js +2 -2
- package/dist/ui/editor/ContentForm.js.map +1 -1
- package/package.json +1 -1
- package/src/engine/ai-dek-notifications.ts +19 -2
- package/src/engine/ai-dek.ts +131 -40
- package/src/engine/foundry-dispatch.ts +110 -24
- package/src/engine/fronts-publish-intent.ts +289 -65
- package/src/engine/fronts-publish.ts +121 -4
- package/src/engine/index.ts +1 -0
- package/src/engine/podcast-source.ts +6 -4
- package/src/engine/publisher.ts +20 -8
- package/src/engine/soft-delete.ts +28 -8
- package/src/engine/video-source.ts +7 -4
- package/src/routes/content.ts +56 -31
- package/src/routes/context.ts +6 -0
- package/src/routes/fronts-publish.ts +213 -2
- package/src/ui/api/_content-config.ts +15 -0
- package/src/ui/api/fronts/schedule.ts +4 -0
- 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
|
-
|
|
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
|
-
|
|
325
|
-
|
|
326
|
-
|
|
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
|
-
|
|
743
|
-
|
|
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
|
-
|
|
828
|
-
if (
|
|
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
|
-
|
|
331
|
-
? podcastPublishBlocker(snapshotRecord(
|
|
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:
|
|
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:
|
|
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
|
|
package/src/engine/index.ts
CHANGED
|
@@ -9,19 +9,21 @@ export interface FrontsPodcastSourceSnapshot {
|
|
|
9
9
|
}
|
|
10
10
|
|
|
11
11
|
/**
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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
|
-
|
|
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,
|