@hubfluencer/mcp 0.16.0 → 0.17.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.
package/src/core.ts CHANGED
@@ -9,6 +9,11 @@
9
9
 
10
10
  import { createHash } from "node:crypto";
11
11
  import { isAbsolute, resolve, sep } from "node:path";
12
+ import {
13
+ type GenerationSummary,
14
+ generationSummarySchema,
15
+ knownGenerationActionTypesFrom,
16
+ } from "./generation-summary.js";
12
17
 
13
18
  export type Kind = "short" | "editor" | "tracking" | "slider";
14
19
 
@@ -265,6 +270,15 @@ export interface NormalizedStatus {
265
270
  ready: boolean;
266
271
  video_url: string | null;
267
272
  error: string | null;
273
+ /** Stable API lifecycle contract, including the idle run_id-null state. */
274
+ generation_summary?: GenerationSummary;
275
+ /** Canonical run identity, null only for the idle projection. */
276
+ run_id?: string | null;
277
+ /** Immutable workflow identity, null for idle projections. */
278
+ workflow?: string | null;
279
+ revision?: number | null;
280
+ poll_after_ms?: number | null;
281
+ available_actions?: string[];
268
282
  /** Short only: exact API failure phase instead of a collapsed terminal state. */
269
283
  failed_stage?: string;
270
284
  /** Short only: stable machine-readable terminal failure classification. */
@@ -275,8 +289,6 @@ export interface NormalizedStatus {
275
289
  failure_source?: string;
276
290
  /** Short only: paid generation attempt number that failed. */
277
291
  failure_attempt?: number;
278
- /** Short only: current paid attempt; echo when cancelling. */
279
- generation_attempt?: number;
280
292
  /** Short only: completed/current Veo clip count for useful long-poll progress. */
281
293
  segments_completed?: number;
282
294
  /** Short only: total current Veo clips in the paid attempt. */
@@ -301,6 +313,21 @@ export interface NormalizedStatus {
301
313
  last_free_rerender_failed?: boolean;
302
314
  }
303
315
 
316
+ /**
317
+ * Lightweight projection of one `GET /video-factories` index row.
318
+ *
319
+ * That generic endpoint spans Editor, campaign, and creative factories and does
320
+ * not embed `generation_summary`; it is discovery metadata, not a lifecycle
321
+ * read. Keep this projection deliberately tolerant and direct callers to the
322
+ * product-specific status tools for authoritative state.
323
+ */
324
+ export interface FactoryIndexProject {
325
+ slug: string;
326
+ kind_detail: string | null;
327
+ stage: string;
328
+ video_url: string | null;
329
+ }
330
+
304
331
  /** The structured fields of an API error envelope errMessage formats from. */
305
332
  export interface ApiErrorFields {
306
333
  status: number;
@@ -320,16 +347,13 @@ export interface ApiErrorFields {
320
347
  *
321
348
  * For a 402 it surfaces the structured credit shortfall the server attaches so
322
349
  * the agent can report exactly how short it is instead of a bare sentence. The
323
- * canonical shape is required_credits/available_credits (tracking render +
324
- * editor render); it falls back to the legacy required/available keys (older
325
- * faceless 402s) so the counts always surface regardless of which the route
326
- * returns.
350
+ * canonical shape is required_credits/available_credits.
327
351
  */
328
352
  export function formatApiErrorMessage(f: ApiErrorFields): string {
329
353
  const base = `API error (HTTP ${f.status}${f.code ? `, ${f.code}` : ""}): ${f.message}`;
330
354
  const body = asRecord(f.body);
331
- const req = body.required_credits ?? body.required;
332
- const have = body.available_credits ?? body.available;
355
+ const req = body.required_credits;
356
+ const have = body.available_credits;
333
357
  if (req != null || have != null) {
334
358
  return `${base} (needs ${req ?? "?"} credits, have ${have ?? "?"}).`;
335
359
  }
@@ -340,6 +364,118 @@ export function asRecord(v: unknown): Record<string, unknown> {
340
364
  return v && typeof v === "object" ? (v as Record<string, unknown>) : {};
341
365
  }
342
366
 
367
+ /** Preserves Short-specific recovery and progress fields across lifecycle projections. */
368
+ function shortProductFields(
369
+ d: Record<string, unknown>,
370
+ ): Partial<
371
+ Pick<
372
+ NormalizedStatus,
373
+ | "failed_stage"
374
+ | "failure_code"
375
+ | "failure_details"
376
+ | "failure_source"
377
+ | "failure_attempt"
378
+ | "segments_completed"
379
+ | "segments_total"
380
+ | "active_segment_positions"
381
+ | "latest_render_free"
382
+ | "last_free_rerender_failed"
383
+ >
384
+ > {
385
+ const fields: Partial<NormalizedStatus> = {};
386
+
387
+ if (typeof d.failed_stage === "string") fields.failed_stage = d.failed_stage;
388
+ if (typeof d.failure_code === "string") fields.failure_code = d.failure_code;
389
+ if (d.failure_details && typeof d.failure_details === "object") {
390
+ fields.failure_details = d.failure_details as Record<string, unknown>;
391
+ }
392
+ if (typeof d.failure_source === "string")
393
+ fields.failure_source = d.failure_source;
394
+ if (typeof d.failure_attempt === "number") {
395
+ fields.failure_attempt = d.failure_attempt;
396
+ }
397
+ if (typeof d.segments_completed === "number") {
398
+ fields.segments_completed = d.segments_completed;
399
+ }
400
+ if (typeof d.segments_total === "number") {
401
+ fields.segments_total = d.segments_total;
402
+ }
403
+ if (
404
+ Array.isArray(d.active_segment_positions) &&
405
+ d.active_segment_positions.every((position) => typeof position === "number")
406
+ ) {
407
+ fields.active_segment_positions = d.active_segment_positions as number[];
408
+ }
409
+ if (typeof d.latest_render_free === "boolean") {
410
+ fields.latest_render_free = d.latest_render_free;
411
+ }
412
+ if (typeof d.last_free_rerender_failed === "boolean") {
413
+ fields.last_free_rerender_failed = d.last_free_rerender_failed;
414
+ }
415
+
416
+ return fields;
417
+ }
418
+
419
+ /** Preserves Editor delivery-freshness diagnostics outside lifecycle authority. */
420
+ function editorProductFields(
421
+ d: Record<string, unknown>,
422
+ latest: Record<string, unknown>,
423
+ ): Pick<NormalizedStatus, "active_segment_ids" | "stale_segment_ids"> &
424
+ Partial<Pick<NormalizedStatus, "stale" | "autopilot_contract_satisfied">> {
425
+ const segments = Array.isArray(d.segments) ? d.segments.map(asRecord) : [];
426
+ const activeSegments = segments.filter((segment) => {
427
+ const status = segment.status;
428
+ const previewStatus = segment.preview_status;
429
+ return (
430
+ status === "processing" ||
431
+ previewStatus === "processing" ||
432
+ previewStatus === "generating"
433
+ );
434
+ });
435
+ const staleSegments = segments.filter(
436
+ (segment) => segment.video_stale === true && segment.status === "completed",
437
+ );
438
+ const stale =
439
+ latest.is_stale === true ||
440
+ staleSegments.length > 0 ||
441
+ d.narration_stale === true ||
442
+ d.voice_stale === true ||
443
+ d.music_stale === true;
444
+ const ids = (rows: Record<string, unknown>[]): Array<number | string> =>
445
+ rows
446
+ .map((row) => row.id)
447
+ .filter(
448
+ (id): id is number | string =>
449
+ typeof id === "number" || typeof id === "string",
450
+ );
451
+
452
+ return {
453
+ // A legacy render without a hash has `is_stale:null`. Preserve that as
454
+ // unknown instead of laundering it into stale:false/current.
455
+ ...(stale || latest.is_stale === false ? { stale } : {}),
456
+ active_segment_ids: ids(activeSegments),
457
+ stale_segment_ids: ids(staleSegments),
458
+ ...(typeof d.autopilot_contract_satisfied === "boolean"
459
+ ? {
460
+ autopilot_contract_satisfied:
461
+ d.autopilot_contract_satisfied as boolean,
462
+ }
463
+ : {}),
464
+ };
465
+ }
466
+
467
+ export function normalizeFactoryIndexProject(
468
+ data: unknown,
469
+ ): FactoryIndexProject {
470
+ const row = asRecord(data);
471
+ return {
472
+ slug: typeof row.slug === "string" ? row.slug : "",
473
+ kind_detail: typeof row.kind === "string" ? row.kind : null,
474
+ stage: typeof row.status === "string" ? row.status : "unknown",
475
+ video_url: typeof row.video_url === "string" ? row.video_url : null,
476
+ };
477
+ }
478
+
343
479
  /** Collapses the short/editor state payloads into one shape the agent can poll. */
344
480
  export function normalizeStatus(
345
481
  kind: Kind,
@@ -348,56 +484,62 @@ export function normalizeStatus(
348
484
  ): NormalizedStatus {
349
485
  const d = asRecord(data);
350
486
  const latest = asRecord(d.latest_render);
351
- const videoUrl = (latest.video_url as string) ?? null;
487
+ const videoUrl =
488
+ typeof latest.video_url === "string" ? latest.video_url : null;
489
+
490
+ if (kind === "short" || kind === "editor") {
491
+ const parsed = generationSummarySchema.safeParse(d.generation_summary);
492
+ if (!parsed.success) {
493
+ throw new Error(
494
+ `The Hubfluencer ${kind} response did not contain a valid generation_summary. Refresh after the API and MCP contracts are upgraded together.`,
495
+ );
496
+ }
352
497
 
353
- if (kind === "short") {
354
- const stage = (d.stage as string) ?? "unknown";
355
- const status: NormalizedStatus = {
498
+ const summary = parsed.data;
499
+ const terminal = [
500
+ "needs_input",
501
+ "completed",
502
+ "failed",
503
+ "cancelled",
504
+ ].includes(summary.status);
505
+ const availableActions = knownGenerationActionTypesFrom(summary);
506
+ const editorFields =
507
+ kind === "editor" ? editorProductFields(d, latest) : null;
508
+ const productFields = editorFields ?? shortProductFields(d);
509
+ // `generation_summary` owns lifecycle state, but the current Editor
510
+ // timeline's delivery freshness is a separate product contract. A completed
511
+ // render artifact may remain downloadable after an edit, so the still-present
512
+ // `is_stale` / `*_stale` wire fields must prevent that historical MP4 from
513
+ // being presented as current.
514
+ const editingRequired =
515
+ kind === "editor" &&
516
+ summary.status === "completed" &&
517
+ editorFields?.stale === true;
518
+ const editorDeliveryFresh =
519
+ kind !== "editor" || editorFields?.stale === false;
520
+ const ready =
521
+ summary.status === "completed" &&
522
+ availableActions.includes("download_result") &&
523
+ !editingRequired &&
524
+ editorDeliveryFresh &&
525
+ Boolean(videoUrl);
526
+
527
+ return {
356
528
  kind,
357
529
  slug,
358
- stage,
359
- terminal: stage === "video_ready" || stage === "failed",
360
- ready: stage === "video_ready",
530
+ stage: editingRequired ? "editing_required" : summary.phase,
531
+ terminal,
532
+ ready,
361
533
  video_url: videoUrl,
362
- error: (d.error_message as string) ?? null,
534
+ error: summary.failure?.message ?? null,
535
+ generation_summary: summary,
536
+ run_id: summary.run_id,
537
+ workflow: summary.workflow,
538
+ revision: summary.revision,
539
+ poll_after_ms: summary.timing.poll_after_ms,
540
+ available_actions: availableActions,
541
+ ...productFields,
363
542
  };
364
- if (typeof d.failed_stage === "string")
365
- status.failed_stage = d.failed_stage;
366
- if (typeof d.failure_code === "string")
367
- status.failure_code = d.failure_code;
368
- if (d.failure_details && typeof d.failure_details === "object")
369
- status.failure_details = d.failure_details as Record<string, unknown>;
370
- if (typeof d.failure_source === "string")
371
- status.failure_source = d.failure_source;
372
- if (typeof d.failure_attempt === "number")
373
- status.failure_attempt = d.failure_attempt;
374
- if (typeof d.generation_attempt === "number")
375
- status.generation_attempt = d.generation_attempt;
376
- if (typeof d.segments_completed === "number")
377
- status.segments_completed = d.segments_completed;
378
- if (typeof d.segments_total === "number")
379
- status.segments_total = d.segments_total;
380
- if (
381
- Array.isArray(d.active_segment_positions) &&
382
- d.active_segment_positions.every(
383
- (position) => typeof position === "number",
384
- )
385
- ) {
386
- status.active_segment_positions = d.active_segment_positions as number[];
387
- }
388
- // Additive free-re-render bookkeeping (2026-07 API): surfaced only when
389
- // the server actually sends the booleans, so older payloads normalize
390
- // byte-identically. A failed free re-render does NOT flip stage to
391
- // "failed" server-side — the delivered video stays visible — so these
392
- // flags are the only signal that the newest render row was a free
393
- // re-render (or a failed one worth retrying, still free).
394
- if (typeof d.latest_render_free === "boolean") {
395
- status.latest_render_free = d.latest_render_free;
396
- }
397
- if (typeof d.last_free_rerender_failed === "boolean") {
398
- status.last_free_rerender_failed = d.last_free_rerender_failed;
399
- }
400
- return status;
401
543
  }
402
544
 
403
545
  if (kind === "tracking") {
@@ -445,388 +587,114 @@ export function normalizeStatus(
445
587
  };
446
588
  }
447
589
 
448
- // editor. A completed render is only "ready" when it still represents the
449
- // CURRENT timeline and no newer scene/audio work is active or stale. The API
450
- // intentionally keeps the last successful MP4 available after edits; treating
451
- // that historical delivery as current made get_status/wait_for_completion
452
- // return immediately while paid regenerations were still running.
453
- const autopilot = (d.autopilot_status as string) ?? "unknown";
454
- const renderStatus = (latest.status as string) ?? null;
455
- const segments = Array.isArray(d.segments) ? d.segments.map(asRecord) : [];
456
- // "Active" means a scene is genuinely mid-generation. Crucially it must NOT
457
- // include the *idle* defaults: segment.status "pending" and preview_status
458
- // "pending" are the never-generated / cleared-preview defaults the API stamps
459
- // on every fresh scene (video_segment.ex: status default "pending",
460
- // preview_status default "pending"). Counting either as active made a fully
461
- // idle draft report stage "segment_processing" with every scene in
462
- // active_segment_ids, so wait_for_completion burned its whole budget on a
463
- // project that isn't generating anything. The real in-flight states are
464
- // segment.status "processing" and preview_status "generating" (its wire enum
465
- // is pending|generating|completed|failed|stale — "processing" is kept only as
466
- // a defensive alias, it never appears).
467
- const activeSegments = segments.filter((segment) => {
468
- const status = segment.status as string | undefined;
469
- const preview = segment.preview_status as string | undefined;
470
- return (
471
- status === "processing" ||
472
- preview === "processing" ||
473
- preview === "generating"
474
- );
475
- });
476
- const staleSegments = segments.filter(
477
- (segment) => segment.video_stale === true && segment.status === "completed",
478
- );
479
- const failedSegments = segments.filter(
480
- (segment) => segment.status === "failed",
481
- );
482
- const batchStatus = (d.batch_generation_status as string) ?? null;
483
- // The batch enum's ACTIVE states (video_factory.ex): preparing (spinning up),
484
- // awaiting_previews (previews generating), and running. "queued" is not a real
485
- // value and was dropped. paused_for_retry is deliberately NOT active: no worker
486
- // owns it until the user explicitly retries or cancels the paused batch.
487
- const batchActive =
488
- batchStatus === "running" ||
489
- batchStatus === "preparing" ||
490
- batchStatus === "awaiting_previews";
491
- // The scene batch commits `completed` before the durable auto-render handoff
492
- // creates its VideoResult row. While the persisted intent remains armed, the
493
- // server still owns the project and a reconciler may create the render at any
494
- // moment. Treat that narrow gap as active work so wait_for_completion cannot
495
- // return an idle/terminal result just before the render appears.
496
- const autoRenderHandoffActive =
497
- batchStatus === "completed" && d.auto_render_after_batch === true;
498
- const batchRetryRequired = batchStatus === "paused_for_retry";
499
- // A parked batch that ran out of credits: it cannot progress without the user
500
- // topping up or reducing scope, so it is a needs-user-action TERMINAL state
501
- // (not active work to wait on).
502
- const batchInsufficientCredits =
503
- batchStatus === "paused_insufficient_credits";
504
- const batchFailed = batchStatus === "failed";
505
- const autopilotActive = [
506
- "pending",
507
- "running",
508
- "generating",
509
- "rendering",
510
- ].includes(autopilot);
511
- const audioStale =
512
- d.narration_stale === true ||
513
- d.voice_stale === true ||
514
- d.music_stale === true;
515
- const renderStale = latest.is_stale === true;
516
- // Only an explicit false proves the completed render matches the live editor.
517
- // Legacy/null/omitted freshness is unknown and must never be promoted to ready.
518
- const renderFresh = latest.is_stale === false;
519
- const stale = renderStale || staleSegments.length > 0 || audioStale;
520
- // A completed Autopilot run the API reports as NOT satisfying its delivery
521
- // contract — gated on a FRESH timeline. The server recomputes
522
- // autopilot_contract_satisfied LIVE for completed runs, folding in staleness,
523
- // so any ordinary post-delivery edit (e.g. adding narration makes music stale)
524
- // flips it false even though the run genuinely delivered. Gating on !stale
525
- // keeps this signal to the real missing-component case — canonically a legacy
526
- // "music-only" run where nothing is stale but voice/narration were never
527
- // produced — and lets the stale/editing_required semantics win everywhere once
528
- // an edit lands. Declared after `stale` (which it depends on); the raw
529
- // autopilot_contract_satisfied wire field is still passed through verbatim
530
- // below, only this derived stage/error signal is freshness-gated.
531
- const autopilotContractIncomplete =
532
- autopilot === "completed" &&
533
- d.autopilot_contract_satisfied === false &&
534
- !stale;
535
- // A render in flight is active work too — fold it into workActive so the
536
- // terminal computation below can never fire while a render is queued/running.
537
- // "pending" here is NOT an idle default: a video_results row only exists once
538
- // a render was actually requested, so a pending latest render is queued work.
539
- const renderActive =
540
- renderStatus === "pending" ||
541
- renderStatus === "rendering" ||
542
- renderStatus === "processing";
543
- // Scenario text generation / scenario apply run as async workers too
544
- // (scenario_status: none|generating|ready|failed; scenario_apply_status:
545
- // idle|pending|applying|failed) — count them as active so a wait right after
546
- // generate_scenario/apply_scenario doesn't bail out as an "idle" draft.
547
- const scenarioActive =
548
- d.scenario_status === "generating" ||
549
- d.scenario_apply_status === "pending" ||
550
- d.scenario_apply_status === "applying";
551
- // Narration generation is a distinct async worker that runs before an
552
- // AudioVersion exists. Without this gate, a project with an older completed
553
- // render looks ready/terminal during narration regeneration and
554
- // wait_for_completion can return that old MP4 while the new script is still
555
- // being produced.
556
- const narrationActive = d.narration_status === "generating";
557
- const scenarioFailed = d.scenario_status === "failed";
558
- const scenarioApplyFailed = d.scenario_apply_status === "failed";
559
- const narrationFailed = d.narration_status === "failed";
560
- // First-time generation is visible through the current selection. A
561
- // regeneration intentionally keeps the prior completed version current until
562
- // the replacement lands, so inspect the version histories as well; otherwise
563
- // an old matching render can look ready while a paid replacement is running.
564
- const currentAudio = asRecord(d.current_audio);
565
- const currentMusic = asRecord(d.current_music);
566
- const audioVersions = Array.isArray(d.audio_versions)
567
- ? d.audio_versions.map(asRecord)
568
- : [];
569
- const musicVersions = Array.isArray(d.music_versions)
570
- ? d.music_versions.map(asRecord)
571
- : [];
572
- const numericVersion = (version: unknown): number =>
573
- typeof version === "number" && Number.isFinite(version) ? version : 0;
574
- const selectedAudioVersion = numericVersion(currentAudio.version);
575
- const selectedMusicVersion = numericVersion(currentMusic.version);
576
- const newerAudioVersions = audioVersions.filter(
577
- (version) => numericVersion(version.version) > selectedAudioVersion,
578
- );
579
- const newerMusicVersions = musicVersions.filter(
580
- (version) => numericVersion(version.version) > selectedMusicVersion,
581
- );
582
- const versionActive = (version: Record<string, unknown>) =>
583
- version.status === "pending" || version.status === "processing";
584
- const newestVersion = (versions: Array<Record<string, unknown>>) =>
585
- versions.reduce<Record<string, unknown> | null>((latest, version) => {
586
- if (!latest) return version;
587
- return numericVersion(version.version) > numericVersion(latest.version)
588
- ? version
589
- : latest;
590
- }, null);
591
- const newestReplacementAudio = newestVersion(newerAudioVersions);
592
- const newestReplacementMusic = newestVersion(newerMusicVersions);
593
- const audioFailed =
594
- currentAudio.status === "failed" ||
595
- currentMusic.status === "failed" ||
596
- newestReplacementAudio?.status === "failed" ||
597
- newestReplacementMusic?.status === "failed";
598
- const audioError = [
599
- newestReplacementAudio?.error_message,
600
- newestReplacementMusic?.error_message,
601
- currentAudio.error_message,
602
- currentMusic.error_message,
603
- ].find(
604
- (value): value is string =>
605
- typeof value === "string" && value.trim().length > 0,
606
- );
607
- const audioActive =
608
- currentAudio.status === "pending" ||
609
- currentAudio.status === "processing" ||
610
- currentMusic.status === "pending" ||
611
- currentMusic.status === "processing" ||
612
- newerAudioVersions.some(versionActive) ||
613
- newerMusicVersions.some(versionActive);
614
- const childWorkActive =
615
- batchActive ||
616
- autoRenderHandoffActive ||
617
- renderActive ||
618
- scenarioActive ||
619
- narrationActive ||
620
- audioActive ||
621
- activeSegments.length > 0;
622
- // A resumed Autopilot briefly exposes the previous attempt's failed child
623
- // states before its new worker reaches and clears them. Suppress that history
624
- // only while real child work is active or for a short, timestamped hand-off
625
- // window. A parent stuck "running" with no worker must eventually surface the
626
- // child failure instead of making wait_for_completion poll forever.
627
- const autopilotStartedAt =
628
- typeof d.autopilot_started_at === "string"
629
- ? Date.parse(d.autopilot_started_at)
630
- : Number.NaN;
631
- const now = Date.now();
632
- const autopilotResumeGraceActive =
633
- autopilotActive &&
634
- Number.isFinite(autopilotStartedAt) &&
635
- autopilotStartedAt <= now &&
636
- now - autopilotStartedAt <= 120_000;
637
- const autopilotFailed = autopilot === "failed" || autopilot === "cancelled";
638
- // Retrying a paused batch deliberately leaves the failed parent Autopilot
639
- // marker in place while the already-paid child batch finishes. In that one
640
- // state combination the parent/old scene failures are history, not a reason
641
- // to return terminal immediately. Once the batch settles, the parent failure
642
- // becomes actionable again and the agent can quote/relaunch Autopilot.
643
- const batchRecoveryActive = autopilotFailed && batchActive;
644
- const childFailureActionable =
645
- !batchRecoveryActive &&
646
- (!autopilotActive || (!childWorkActive && !autopilotResumeGraceActive));
647
- const workActive = autopilotActive || childWorkActive;
648
- // Orchestration failures are historical once a repaired timeline has a fresh,
649
- // completed MP4. Keeping them terminal would permanently block download_result
650
- // after a successful granular recovery.
651
- const deliveredArtifactSupersedesOrchestrationFailure =
652
- renderStatus === "completed" &&
653
- Boolean(videoUrl) &&
654
- renderFresh &&
655
- !stale &&
656
- !workActive &&
657
- failedSegments.length === 0 &&
658
- !audioFailed &&
659
- !autopilotContractIncomplete;
660
- const ready = deliveredArtifactSupersedesOrchestrationFailure;
661
- const parentFailureActionable = autopilotFailed && !batchRecoveryActive;
662
- const renderFailed = renderStatus === "failed" && childFailureActionable;
663
- const failed =
664
- (parentFailureActionable &&
665
- !deliveredArtifactSupersedesOrchestrationFailure) ||
666
- renderFailed ||
667
- (childFailureActionable &&
668
- scenarioApplyFailed &&
669
- !deliveredArtifactSupersedesOrchestrationFailure) ||
670
- (childFailureActionable &&
671
- scenarioFailed &&
672
- !deliveredArtifactSupersedesOrchestrationFailure) ||
673
- (childFailureActionable &&
674
- narrationFailed &&
675
- !deliveredArtifactSupersedesOrchestrationFailure) ||
676
- (childFailureActionable &&
677
- batchFailed &&
678
- !deliveredArtifactSupersedesOrchestrationFailure) ||
679
- (childFailureActionable && failedSegments.length > 0) ||
680
- (childFailureActionable && audioFailed);
681
- // Terminal = control should return to the agent because nothing will progress
682
- // without another tool call. With every async job type folded into workActive
683
- // above (autopilot, batch, segments, scenario, narration, audio, render), NO
684
- // work active means exactly that — whether the project is ready, failed,
685
- // stale (editing_required), parked on credits, or a pristine idle draft, only
686
- // a new tool call can move it, so wait_for_completion must return instead of
687
- // polling its full budget. (ready is subsumed by !workActive but kept for
688
- // clarity; explicit failure states are terminal even while cleanup work spins.)
689
- const terminal =
690
- ready ||
691
- failed ||
692
- batchRetryRequired ||
693
- batchInsufficientCredits ||
694
- !workActive;
695
- let stage = autopilot;
696
- // Failure always wins over an unrelated active flag. Workers can leave an
697
- // outer autopilot/batch state active after a child stage has failed; reporting
698
- // the active stage would make agents poll forever instead of taking action.
699
- if (ready) stage = "completed";
700
- // A paused child batch is the immediate recovery gate even when its parent
701
- // Autopilot run has already failed/cancelled. Reporting only the generic
702
- // parent terminal state hides the one action that must happen before a new
703
- // Autopilot run is allowed to start.
704
- else if (batchRetryRequired) stage = "batch_retry_required";
705
- else if (parentFailureActionable && autopilot === "failed") stage = "failed";
706
- else if (parentFailureActionable && autopilot === "cancelled")
707
- stage = "cancelled";
708
- else if (renderFailed) stage = "render_failed";
709
- else if (childFailureActionable && scenarioApplyFailed)
710
- stage = "scenario_apply_failed";
711
- else if (childFailureActionable && scenarioFailed) stage = "scenario_failed";
712
- else if (childFailureActionable && narrationFailed)
713
- stage = "narration_failed";
714
- else if (childFailureActionable && batchFailed) stage = "batch_failed";
715
- else if (childFailureActionable && failedSegments.length > 0)
716
- stage = "segment_failed";
717
- else if (childFailureActionable && audioFailed) stage = "audio_failed";
718
- else if (batchInsufficientCredits) stage = "insufficient_credits";
719
- else if (autopilotContractIncomplete) stage = "contract_incomplete";
720
- else if (activeSegments.length > 0) stage = "segment_processing";
721
- else if (batchActive) stage = "segment_generation";
722
- else if (autoRenderHandoffActive) stage = "rendering";
723
- else if (renderActive) stage = "rendering";
724
- else if (scenarioActive) stage = "scenario_processing";
725
- else if (narrationActive) stage = "narration_processing";
726
- else if (audioActive) stage = "audio_processing";
727
- else if (!workActive && stale) stage = "editing_required";
728
- const segmentError = failedSegments
729
- .map((segment) => segment.error_message)
730
- .find(
731
- (value): value is string => typeof value === "string" && value.length > 0,
590
+ throw new Error(`Unsupported Hubfluencer project kind: ${String(kind)}`);
591
+ }
592
+
593
+ export class GenerationStartStateContractError extends Error {
594
+ constructor(reason: string) {
595
+ super(
596
+ `Invalid canonical generation summary for start discrimination: ${reason}`,
732
597
  );
733
- const actionableChildError = childFailureActionable
734
- ? ((latest.error_message as string) ??
735
- (d.scenario_apply_error_message as string) ??
736
- (d.scenario_error_message as string) ??
737
- (d.narration_error_message as string) ??
738
- (d.batch_generation_error_message as string) ??
739
- segmentError ??
740
- audioError)
741
- : null;
742
- return {
743
- kind,
744
- slug,
745
- stage,
746
- terminal,
747
- ready,
748
- video_url: videoUrl,
749
- // Mirror the stage chain's precedence EXACTLY so the error text always
750
- // matches the stage the caller sees. batch-retry, then the actionable
751
- // parent/child failures, then insufficient-credits, and only last the
752
- // contract-incomplete message — because a completed-autopilot editor with a
753
- // later paused manual batch reports stage "batch_retry_required", so it must
754
- // read the retry guidance, not "Resume Autopilot" (the one action
755
- // start_autopilot refuses while a batch is paused).
756
- error: ready
757
- ? null
758
- : batchRetryRequired
759
- ? "Batch generation is paused after a failed scene. Call retry_editor_batch or cancel_editor_batch before continuing."
760
- : ((parentFailureActionable
761
- ? (d.autopilot_error_message as string)
762
- : null) ??
763
- actionableChildError ??
764
- (batchInsufficientCredits
765
- ? "Batch generation paused: not enough credits to finish. Top up or reduce scope, then invoke the generation/render path again; retry_editor_batch only applies to paused_for_retry."
766
- : null) ??
767
- (autopilotContractIncomplete
768
- ? "Autopilot completed without every quoted deliverable. Resume Autopilot to repair the missing component."
769
- : null)),
770
- stale,
771
- active_segment_ids: activeSegments
772
- .map((segment) => segment.id)
773
- .filter(
774
- (id): id is number | string =>
775
- typeof id === "number" || typeof id === "string",
776
- ),
777
- stale_segment_ids: staleSegments
778
- .map((segment) => segment.id)
779
- .filter(
780
- (id): id is number | string =>
781
- typeof id === "number" || typeof id === "string",
782
- ),
783
- ...(typeof d.autopilot_contract_satisfied === "boolean"
784
- ? {
785
- autopilot_contract_satisfied:
786
- d.autopilot_contract_satisfied as boolean,
787
- }
788
- : {}),
789
- };
598
+ this.name = "GenerationStartStateContractError";
599
+ }
790
600
  }
791
601
 
792
602
  /**
793
- * A discriminator folded into make_video's start idempotency key so a re-run
794
- * AFTER a terminal failure genuinely restarts instead of replaying the failed
795
- * run's cached 202 for 24h (the server caches idempotent POSTs). Mirrors the
796
- * tracking render key's anti-replay design (which folds the failed result id):
603
+ * Canonical discriminator folded into make_video's start idempotency key.
797
604
  *
798
- * - Not terminally failed (fresh draft, in flight, or completed) → "start", a
799
- * STABLE token. A transport retry of the same start therefore reuses the key
800
- * and dedupes (a double-start is also independently blocked server-side by
801
- * the already-running / 409 gate), so no double charge.
802
- * - Terminally failed a signature of the per-attempt-varying render
803
- * id/version (plus the failure step/stage), so a re-run reads the failed
804
- * state, mints a DIFFERENT key, and re-enqueues the pipeline. Each subsequent
805
- * rendered failure changes latest_render.id, so successive retries keep
806
- * minting fresh keys rather than replaying a dead 202.
605
+ * The stable `start` token deliberately survives idle, accepted/nonterminal,
606
+ * completed, and non-restartable terminal snapshots. That preserves exact
607
+ * transport replay of the original logical start. Only a terminal snapshot
608
+ * which explicitly advertises the server-owned `start_generation` capability
609
+ * mints a new key, using canonical run/revision/failure identity. Product detail
610
+ * fields are never consulted here.
807
611
  *
808
- * Pure: derived solely from the status payload the caller already fetched.
612
+ * A response that is missing or violates the stable summary contract fails
613
+ * closed. Starting chargeable work without a trustworthy authority snapshot
614
+ * would turn a contract/version outage into an unsafe new intent.
809
615
  */
810
616
  export function startStateDiscriminator(kind: Kind, data: unknown): string {
811
- const d = asRecord(data);
812
- const latest = asRecord(d.latest_render);
813
- const renderSig = `${latest.id ?? ""}:${latest.version ?? ""}`;
617
+ if (kind !== "short" && kind !== "editor") {
618
+ throw new GenerationStartStateContractError(`unsupported product ${kind}`);
619
+ }
620
+
621
+ const parsed = generationSummarySchema.safeParse(
622
+ asRecord(data).generation_summary,
623
+ );
624
+ if (!parsed.success) {
625
+ throw new GenerationStartStateContractError(
626
+ "missing or malformed generation_summary",
627
+ );
628
+ }
629
+
630
+ const summary = parsed.data;
631
+ validateSummaryProduct(kind, summary);
632
+
633
+ const restartAvailable = summary.available_actions.some(
634
+ (action) => action.type === "start_generation",
635
+ );
636
+ const restartableTerminal =
637
+ restartAvailable &&
638
+ (summary.status === "failed" || summary.status === "cancelled");
639
+
640
+ if (!restartableTerminal) return "start";
641
+
642
+ const attemptIdentity = terminalAttemptIdentity(summary);
643
+ return `restart:${idemKey(
644
+ "generation-restart",
645
+ kind,
646
+ attemptIdentity,
647
+ summary.workflow ?? "",
648
+ String(summary.workflow_version ?? ""),
649
+ String(summary.revision ?? ""),
650
+ summary.status,
651
+ summary.failure?.code ?? "",
652
+ )}`;
653
+ }
654
+
655
+ function validateSummaryProduct(
656
+ kind: "short" | "editor",
657
+ summary: GenerationSummary,
658
+ ): void {
659
+ if (summary.status === "idle") {
660
+ if (summary.run_id !== null || summary.workflow !== null) {
661
+ throw new GenerationStartStateContractError(
662
+ "idle summary carries run identity",
663
+ );
664
+ }
665
+ return;
666
+ }
667
+
668
+ const workflow = summary.workflow;
669
+ if (workflow === null) {
670
+ throw new GenerationStartStateContractError(
671
+ "non-idle summary has no workflow",
672
+ );
673
+ }
674
+
675
+ const belongsToProduct =
676
+ kind === "short"
677
+ ? workflow.startsWith("short.")
678
+ : workflow.startsWith("editor.");
679
+ if (!belongsToProduct) {
680
+ throw new GenerationStartStateContractError("workflow/product mismatch");
681
+ }
682
+
683
+ if (summary.run_id === null) {
684
+ throw new GenerationStartStateContractError(
685
+ "run identity/workflow mismatch",
686
+ );
687
+ }
688
+ }
814
689
 
815
- if (kind === "short") {
816
- const stage = (d.stage as string) ?? "";
817
- if (stage !== "failed") return "start";
818
- return `failed:${d.failed_stage ?? ""}:${renderSig}`;
690
+ function terminalAttemptIdentity(summary: GenerationSummary): string {
691
+ if (summary.run_id === null) {
692
+ throw new GenerationStartStateContractError(
693
+ "restartable terminal summary has no durable attempt identity",
694
+ );
819
695
  }
820
696
 
821
- // editor
822
- const autopilot = (d.autopilot_status as string) ?? "";
823
- const renderStatus = (latest.status as string) ?? "";
824
- const failed =
825
- autopilot === "failed" ||
826
- autopilot === "cancelled" ||
827
- renderStatus === "failed";
828
- if (!failed) return "start";
829
- return `failed:${autopilot}:${d.autopilot_error_step ?? ""}:${renderSig}`;
697
+ return summary.run_id;
830
698
  }
831
699
 
832
700
  /** Stable idempotency key for a logical operation, so a retried call is safe. */
@@ -971,14 +839,17 @@ export function editorAdCreateKey(a: EditorAdKeyArgs): string {
971
839
 
972
840
  /**
973
841
  * create_editor_ad's autopilot-start key (`POST /editor/:slug/autopilot`). Folds
974
- * the create params AND the approved max_credits cap. There is no startState
975
- * discriminator (this tool starts right after create, so there is no prior
976
- * terminal state to guard against), so max_credits is what lets a re-call with a
977
- * RAISED cap after a failed/over-cap launch mint a fresh key and re-enqueue
978
- * rather than replay the server's 24h-cached dead 202. Self-consistent per-tool
979
- * only.
842
+ * the create params, approved max_credits cap, AND the canonical start-state
843
+ * discriminator observed immediately before launch. The create endpoint is
844
+ * idempotent and can return an existing failed/cancelled draft, so the start key
845
+ * must rotate with that durable attempt identity instead of replaying an older
846
+ * 24h-cached 202. Self-consistent per-tool only.
980
847
  */
981
- export function editorAdAutopilotKey(slug: string, a: EditorAdKeyArgs): string {
848
+ export function editorAdAutopilotKey(
849
+ slug: string,
850
+ a: EditorAdKeyArgs,
851
+ startState: string,
852
+ ): string {
982
853
  return idemKey(
983
854
  "autopilot",
984
855
  slug,
@@ -1002,6 +873,7 @@ export function editorAdAutopilotKey(slug: string, a: EditorAdKeyArgs): string {
1002
873
  ? ""
1003
874
  : String(a.logo_duration_seconds),
1004
875
  a.max_credits === undefined ? "" : String(a.max_credits),
876
+ startState,
1005
877
  );
1006
878
  }
1007
879