@hubfluencer/mcp 0.15.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. */
@@ -289,6 +301,8 @@ export interface NormalizedStatus {
289
301
  active_segment_ids?: Array<number | string>;
290
302
  /** Editor only: completed current scene ids invalidated by continuity edits. */
291
303
  stale_segment_ids?: Array<number | string>;
304
+ /** Editor Autopilot only: server-verified quoted delivery contract. */
305
+ autopilot_contract_satisfied?: boolean;
292
306
  /** Short only (additive, absent on older APIs): the newest render row is a free re-render. */
293
307
  latest_render_free?: boolean;
294
308
  /**
@@ -299,6 +313,21 @@ export interface NormalizedStatus {
299
313
  last_free_rerender_failed?: boolean;
300
314
  }
301
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
+
302
331
  /** The structured fields of an API error envelope errMessage formats from. */
303
332
  export interface ApiErrorFields {
304
333
  status: number;
@@ -318,16 +347,13 @@ export interface ApiErrorFields {
318
347
  *
319
348
  * For a 402 it surfaces the structured credit shortfall the server attaches so
320
349
  * the agent can report exactly how short it is instead of a bare sentence. The
321
- * canonical shape is required_credits/available_credits (tracking render +
322
- * editor render); it falls back to the legacy required/available keys (older
323
- * faceless 402s) so the counts always surface regardless of which the route
324
- * returns.
350
+ * canonical shape is required_credits/available_credits.
325
351
  */
326
352
  export function formatApiErrorMessage(f: ApiErrorFields): string {
327
353
  const base = `API error (HTTP ${f.status}${f.code ? `, ${f.code}` : ""}): ${f.message}`;
328
354
  const body = asRecord(f.body);
329
- const req = body.required_credits ?? body.required;
330
- const have = body.available_credits ?? body.available;
355
+ const req = body.required_credits;
356
+ const have = body.available_credits;
331
357
  if (req != null || have != null) {
332
358
  return `${base} (needs ${req ?? "?"} credits, have ${have ?? "?"}).`;
333
359
  }
@@ -338,6 +364,118 @@ export function asRecord(v: unknown): Record<string, unknown> {
338
364
  return v && typeof v === "object" ? (v as Record<string, unknown>) : {};
339
365
  }
340
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
+
341
479
  /** Collapses the short/editor state payloads into one shape the agent can poll. */
342
480
  export function normalizeStatus(
343
481
  kind: Kind,
@@ -346,56 +484,62 @@ export function normalizeStatus(
346
484
  ): NormalizedStatus {
347
485
  const d = asRecord(data);
348
486
  const latest = asRecord(d.latest_render);
349
- 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
+ }
497
+
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);
350
526
 
351
- if (kind === "short") {
352
- const stage = (d.stage as string) ?? "unknown";
353
- const status: NormalizedStatus = {
527
+ return {
354
528
  kind,
355
529
  slug,
356
- stage,
357
- terminal: stage === "video_ready" || stage === "failed",
358
- ready: stage === "video_ready",
530
+ stage: editingRequired ? "editing_required" : summary.phase,
531
+ terminal,
532
+ ready,
359
533
  video_url: videoUrl,
360
- 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,
361
542
  };
362
- if (typeof d.failed_stage === "string")
363
- status.failed_stage = d.failed_stage;
364
- if (typeof d.failure_code === "string")
365
- status.failure_code = d.failure_code;
366
- if (d.failure_details && typeof d.failure_details === "object")
367
- status.failure_details = d.failure_details as Record<string, unknown>;
368
- if (typeof d.failure_source === "string")
369
- status.failure_source = d.failure_source;
370
- if (typeof d.failure_attempt === "number")
371
- status.failure_attempt = d.failure_attempt;
372
- if (typeof d.generation_attempt === "number")
373
- status.generation_attempt = d.generation_attempt;
374
- if (typeof d.segments_completed === "number")
375
- status.segments_completed = d.segments_completed;
376
- if (typeof d.segments_total === "number")
377
- status.segments_total = d.segments_total;
378
- if (
379
- Array.isArray(d.active_segment_positions) &&
380
- d.active_segment_positions.every(
381
- (position) => typeof position === "number",
382
- )
383
- ) {
384
- status.active_segment_positions = d.active_segment_positions as number[];
385
- }
386
- // Additive free-re-render bookkeeping (2026-07 API): surfaced only when
387
- // the server actually sends the booleans, so older payloads normalize
388
- // byte-identically. A failed free re-render does NOT flip stage to
389
- // "failed" server-side — the delivered video stays visible — so these
390
- // flags are the only signal that the newest render row was a free
391
- // re-render (or a failed one worth retrying, still free).
392
- if (typeof d.latest_render_free === "boolean") {
393
- status.latest_render_free = d.latest_render_free;
394
- }
395
- if (typeof d.last_free_rerender_failed === "boolean") {
396
- status.last_free_rerender_failed = d.last_free_rerender_failed;
397
- }
398
- return status;
399
543
  }
400
544
 
401
545
  if (kind === "tracking") {
@@ -443,355 +587,114 @@ export function normalizeStatus(
443
587
  };
444
588
  }
445
589
 
446
- // editor. A completed render is only "ready" when it still represents the
447
- // CURRENT timeline and no newer scene/audio work is active or stale. The API
448
- // intentionally keeps the last successful MP4 available after edits; treating
449
- // that historical delivery as current made get_status/wait_for_completion
450
- // return immediately while paid regenerations were still running.
451
- const autopilot = (d.autopilot_status as string) ?? "unknown";
452
- const renderStatus = (latest.status as string) ?? null;
453
- const segments = Array.isArray(d.segments) ? d.segments.map(asRecord) : [];
454
- // "Active" means a scene is genuinely mid-generation. Crucially it must NOT
455
- // include the *idle* defaults: segment.status "pending" and preview_status
456
- // "pending" are the never-generated / cleared-preview defaults the API stamps
457
- // on every fresh scene (video_segment.ex: status default "pending",
458
- // preview_status default "pending"). Counting either as active made a fully
459
- // idle draft report stage "segment_processing" with every scene in
460
- // active_segment_ids, so wait_for_completion burned its whole budget on a
461
- // project that isn't generating anything. The real in-flight states are
462
- // segment.status "processing" and preview_status "generating" (its wire enum
463
- // is pending|generating|completed|failed|stale — "processing" is kept only as
464
- // a defensive alias, it never appears).
465
- const activeSegments = segments.filter((segment) => {
466
- const status = segment.status as string | undefined;
467
- const preview = segment.preview_status as string | undefined;
468
- return (
469
- status === "processing" ||
470
- preview === "processing" ||
471
- preview === "generating"
472
- );
473
- });
474
- const staleSegments = segments.filter(
475
- (segment) => segment.video_stale === true && segment.status === "completed",
476
- );
477
- const failedSegments = segments.filter(
478
- (segment) => segment.status === "failed",
479
- );
480
- const batchStatus = (d.batch_generation_status as string) ?? null;
481
- // The batch enum's ACTIVE states (video_factory.ex): preparing (spinning up),
482
- // awaiting_previews (previews generating), and running. "queued" is not a real
483
- // value and was dropped. paused_for_retry is deliberately NOT active: no worker
484
- // owns it until the user explicitly retries or cancels the paused batch.
485
- const batchActive =
486
- batchStatus === "running" ||
487
- batchStatus === "preparing" ||
488
- batchStatus === "awaiting_previews";
489
- // The scene batch commits `completed` before the durable auto-render handoff
490
- // creates its VideoResult row. While the persisted intent remains armed, the
491
- // server still owns the project and a reconciler may create the render at any
492
- // moment. Treat that narrow gap as active work so wait_for_completion cannot
493
- // return an idle/terminal result just before the render appears.
494
- const autoRenderHandoffActive =
495
- batchStatus === "completed" && d.auto_render_after_batch === true;
496
- const batchRetryRequired = batchStatus === "paused_for_retry";
497
- // A parked batch that ran out of credits: it cannot progress without the user
498
- // topping up or reducing scope, so it is a needs-user-action TERMINAL state
499
- // (not active work to wait on).
500
- const batchInsufficientCredits =
501
- batchStatus === "paused_insufficient_credits";
502
- const batchFailed = batchStatus === "failed";
503
- const autopilotActive = [
504
- "pending",
505
- "running",
506
- "generating",
507
- "rendering",
508
- ].includes(autopilot);
509
- const audioStale =
510
- d.narration_stale === true ||
511
- d.voice_stale === true ||
512
- d.music_stale === true;
513
- const renderStale = latest.is_stale === true;
514
- // Only an explicit false proves the completed render matches the live editor.
515
- // Legacy/null/omitted freshness is unknown and must never be promoted to ready.
516
- const renderFresh = latest.is_stale === false;
517
- const stale = renderStale || staleSegments.length > 0 || audioStale;
518
- // A render in flight is active work too — fold it into workActive so the
519
- // terminal computation below can never fire while a render is queued/running.
520
- // "pending" here is NOT an idle default: a video_results row only exists once
521
- // a render was actually requested, so a pending latest render is queued work.
522
- const renderActive =
523
- renderStatus === "pending" ||
524
- renderStatus === "rendering" ||
525
- renderStatus === "processing";
526
- // Scenario text generation / scenario apply run as async workers too
527
- // (scenario_status: none|generating|ready|failed; scenario_apply_status:
528
- // idle|pending|applying|failed) — count them as active so a wait right after
529
- // generate_scenario/apply_scenario doesn't bail out as an "idle" draft.
530
- const scenarioActive =
531
- d.scenario_status === "generating" ||
532
- d.scenario_apply_status === "pending" ||
533
- d.scenario_apply_status === "applying";
534
- // Narration generation is a distinct async worker that runs before an
535
- // AudioVersion exists. Without this gate, a project with an older completed
536
- // render looks ready/terminal during narration regeneration and
537
- // wait_for_completion can return that old MP4 while the new script is still
538
- // being produced.
539
- const narrationActive = d.narration_status === "generating";
540
- const scenarioFailed = d.scenario_status === "failed";
541
- const scenarioApplyFailed = d.scenario_apply_status === "failed";
542
- const narrationFailed = d.narration_status === "failed";
543
- // First-time generation is visible through the current selection. A
544
- // regeneration intentionally keeps the prior completed version current until
545
- // the replacement lands, so inspect the version histories as well; otherwise
546
- // an old matching render can look ready while a paid replacement is running.
547
- const currentAudio = asRecord(d.current_audio);
548
- const currentMusic = asRecord(d.current_music);
549
- const audioVersions = Array.isArray(d.audio_versions)
550
- ? d.audio_versions.map(asRecord)
551
- : [];
552
- const musicVersions = Array.isArray(d.music_versions)
553
- ? d.music_versions.map(asRecord)
554
- : [];
555
- const numericVersion = (version: unknown): number =>
556
- typeof version === "number" && Number.isFinite(version) ? version : 0;
557
- const selectedAudioVersion = numericVersion(currentAudio.version);
558
- const selectedMusicVersion = numericVersion(currentMusic.version);
559
- const newerAudioVersions = audioVersions.filter(
560
- (version) => numericVersion(version.version) > selectedAudioVersion,
561
- );
562
- const newerMusicVersions = musicVersions.filter(
563
- (version) => numericVersion(version.version) > selectedMusicVersion,
564
- );
565
- const versionActive = (version: Record<string, unknown>) =>
566
- version.status === "pending" || version.status === "processing";
567
- const newestVersion = (versions: Array<Record<string, unknown>>) =>
568
- versions.reduce<Record<string, unknown> | null>((latest, version) => {
569
- if (!latest) return version;
570
- return numericVersion(version.version) > numericVersion(latest.version)
571
- ? version
572
- : latest;
573
- }, null);
574
- const newestReplacementAudio = newestVersion(newerAudioVersions);
575
- const newestReplacementMusic = newestVersion(newerMusicVersions);
576
- const audioFailed =
577
- currentAudio.status === "failed" ||
578
- currentMusic.status === "failed" ||
579
- newestReplacementAudio?.status === "failed" ||
580
- newestReplacementMusic?.status === "failed";
581
- const audioError = [
582
- newestReplacementAudio?.error_message,
583
- newestReplacementMusic?.error_message,
584
- currentAudio.error_message,
585
- currentMusic.error_message,
586
- ].find(
587
- (value): value is string =>
588
- typeof value === "string" && value.trim().length > 0,
589
- );
590
- const audioActive =
591
- currentAudio.status === "pending" ||
592
- currentAudio.status === "processing" ||
593
- currentMusic.status === "pending" ||
594
- currentMusic.status === "processing" ||
595
- newerAudioVersions.some(versionActive) ||
596
- newerMusicVersions.some(versionActive);
597
- const childWorkActive =
598
- batchActive ||
599
- autoRenderHandoffActive ||
600
- renderActive ||
601
- scenarioActive ||
602
- narrationActive ||
603
- audioActive ||
604
- activeSegments.length > 0;
605
- // A resumed Autopilot briefly exposes the previous attempt's failed child
606
- // states before its new worker reaches and clears them. Suppress that history
607
- // only while real child work is active or for a short, timestamped hand-off
608
- // window. A parent stuck "running" with no worker must eventually surface the
609
- // child failure instead of making wait_for_completion poll forever.
610
- const autopilotStartedAt =
611
- typeof d.autopilot_started_at === "string"
612
- ? Date.parse(d.autopilot_started_at)
613
- : Number.NaN;
614
- const now = Date.now();
615
- const autopilotResumeGraceActive =
616
- autopilotActive &&
617
- Number.isFinite(autopilotStartedAt) &&
618
- autopilotStartedAt <= now &&
619
- now - autopilotStartedAt <= 120_000;
620
- const autopilotFailed = autopilot === "failed" || autopilot === "cancelled";
621
- // Retrying a paused batch deliberately leaves the failed parent Autopilot
622
- // marker in place while the already-paid child batch finishes. In that one
623
- // state combination the parent/old scene failures are history, not a reason
624
- // to return terminal immediately. Once the batch settles, the parent failure
625
- // becomes actionable again and the agent can quote/relaunch Autopilot.
626
- const batchRecoveryActive = autopilotFailed && batchActive;
627
- const childFailureActionable =
628
- !batchRecoveryActive &&
629
- (!autopilotActive || (!childWorkActive && !autopilotResumeGraceActive));
630
- const workActive = autopilotActive || childWorkActive;
631
- // Orchestration failures are historical once a repaired timeline has a fresh,
632
- // completed MP4. Keeping them terminal would permanently block download_result
633
- // after a successful granular recovery.
634
- const deliveredArtifactSupersedesOrchestrationFailure =
635
- renderStatus === "completed" &&
636
- Boolean(videoUrl) &&
637
- renderFresh &&
638
- !stale &&
639
- !workActive &&
640
- failedSegments.length === 0 &&
641
- !audioFailed;
642
- const ready = deliveredArtifactSupersedesOrchestrationFailure;
643
- const parentFailureActionable = autopilotFailed && !batchRecoveryActive;
644
- const renderFailed = renderStatus === "failed" && childFailureActionable;
645
- const failed =
646
- (parentFailureActionable &&
647
- !deliveredArtifactSupersedesOrchestrationFailure) ||
648
- renderFailed ||
649
- (childFailureActionable &&
650
- scenarioApplyFailed &&
651
- !deliveredArtifactSupersedesOrchestrationFailure) ||
652
- (childFailureActionable &&
653
- scenarioFailed &&
654
- !deliveredArtifactSupersedesOrchestrationFailure) ||
655
- (childFailureActionable &&
656
- narrationFailed &&
657
- !deliveredArtifactSupersedesOrchestrationFailure) ||
658
- (childFailureActionable &&
659
- batchFailed &&
660
- !deliveredArtifactSupersedesOrchestrationFailure) ||
661
- (childFailureActionable && failedSegments.length > 0) ||
662
- (childFailureActionable && audioFailed);
663
- // Terminal = control should return to the agent because nothing will progress
664
- // without another tool call. With every async job type folded into workActive
665
- // above (autopilot, batch, segments, scenario, narration, audio, render), NO
666
- // work active means exactly that — whether the project is ready, failed,
667
- // stale (editing_required), parked on credits, or a pristine idle draft, only
668
- // a new tool call can move it, so wait_for_completion must return instead of
669
- // polling its full budget. (ready is subsumed by !workActive but kept for
670
- // clarity; explicit failure states are terminal even while cleanup work spins.)
671
- const terminal =
672
- ready ||
673
- failed ||
674
- batchRetryRequired ||
675
- batchInsufficientCredits ||
676
- !workActive;
677
- let stage = autopilot;
678
- // Failure always wins over an unrelated active flag. Workers can leave an
679
- // outer autopilot/batch state active after a child stage has failed; reporting
680
- // the active stage would make agents poll forever instead of taking action.
681
- if (ready) stage = "completed";
682
- // A paused child batch is the immediate recovery gate even when its parent
683
- // Autopilot run has already failed/cancelled. Reporting only the generic
684
- // parent terminal state hides the one action that must happen before a new
685
- // Autopilot run is allowed to start.
686
- else if (batchRetryRequired) stage = "batch_retry_required";
687
- else if (parentFailureActionable && autopilot === "failed") stage = "failed";
688
- else if (parentFailureActionable && autopilot === "cancelled")
689
- stage = "cancelled";
690
- else if (renderFailed) stage = "render_failed";
691
- else if (childFailureActionable && scenarioApplyFailed)
692
- stage = "scenario_apply_failed";
693
- else if (childFailureActionable && scenarioFailed) stage = "scenario_failed";
694
- else if (childFailureActionable && narrationFailed)
695
- stage = "narration_failed";
696
- else if (childFailureActionable && batchFailed) stage = "batch_failed";
697
- else if (childFailureActionable && failedSegments.length > 0)
698
- stage = "segment_failed";
699
- else if (childFailureActionable && audioFailed) stage = "audio_failed";
700
- else if (batchInsufficientCredits) stage = "insufficient_credits";
701
- else if (activeSegments.length > 0) stage = "segment_processing";
702
- else if (batchActive) stage = "segment_generation";
703
- else if (autoRenderHandoffActive) stage = "rendering";
704
- else if (renderActive) stage = "rendering";
705
- else if (scenarioActive) stage = "scenario_processing";
706
- else if (narrationActive) stage = "narration_processing";
707
- else if (audioActive) stage = "audio_processing";
708
- else if (!workActive && stale) stage = "editing_required";
709
- const segmentError = failedSegments
710
- .map((segment) => segment.error_message)
711
- .find(
712
- (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}`,
713
597
  );
714
- const actionableChildError = childFailureActionable
715
- ? ((latest.error_message as string) ??
716
- (d.scenario_apply_error_message as string) ??
717
- (d.scenario_error_message as string) ??
718
- (d.narration_error_message as string) ??
719
- (d.batch_generation_error_message as string) ??
720
- segmentError ??
721
- audioError)
722
- : null;
723
- return {
724
- kind,
725
- slug,
726
- stage,
727
- terminal,
728
- ready,
729
- video_url: videoUrl,
730
- error: ready
731
- ? null
732
- : batchRetryRequired
733
- ? "Batch generation is paused after a failed scene. Call retry_editor_batch or cancel_editor_batch before continuing."
734
- : ((parentFailureActionable
735
- ? (d.autopilot_error_message as string)
736
- : null) ??
737
- actionableChildError ??
738
- (batchInsufficientCredits
739
- ? "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."
740
- : null)),
741
- stale,
742
- active_segment_ids: activeSegments
743
- .map((segment) => segment.id)
744
- .filter(
745
- (id): id is number | string =>
746
- typeof id === "number" || typeof id === "string",
747
- ),
748
- stale_segment_ids: staleSegments
749
- .map((segment) => segment.id)
750
- .filter(
751
- (id): id is number | string =>
752
- typeof id === "number" || typeof id === "string",
753
- ),
754
- };
598
+ this.name = "GenerationStartStateContractError";
599
+ }
755
600
  }
756
601
 
757
602
  /**
758
- * A discriminator folded into make_video's start idempotency key so a re-run
759
- * AFTER a terminal failure genuinely restarts instead of replaying the failed
760
- * run's cached 202 for 24h (the server caches idempotent POSTs). Mirrors the
761
- * tracking render key's anti-replay design (which folds the failed result id):
603
+ * Canonical discriminator folded into make_video's start idempotency key.
762
604
  *
763
- * - Not terminally failed (fresh draft, in flight, or completed) → "start", a
764
- * STABLE token. A transport retry of the same start therefore reuses the key
765
- * and dedupes (a double-start is also independently blocked server-side by
766
- * the already-running / 409 gate), so no double charge.
767
- * - Terminally failed a signature of the per-attempt-varying render
768
- * id/version (plus the failure step/stage), so a re-run reads the failed
769
- * state, mints a DIFFERENT key, and re-enqueues the pipeline. Each subsequent
770
- * rendered failure changes latest_render.id, so successive retries keep
771
- * 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.
772
611
  *
773
- * 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.
774
615
  */
775
616
  export function startStateDiscriminator(kind: Kind, data: unknown): string {
776
- const d = asRecord(data);
777
- const latest = asRecord(d.latest_render);
778
- const renderSig = `${latest.id ?? ""}:${latest.version ?? ""}`;
617
+ if (kind !== "short" && kind !== "editor") {
618
+ throw new GenerationStartStateContractError(`unsupported product ${kind}`);
619
+ }
779
620
 
780
- if (kind === "short") {
781
- const stage = (d.stage as string) ?? "";
782
- if (stage !== "failed") return "start";
783
- return `failed:${d.failed_stage ?? ""}:${renderSig}`;
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
+ );
784
628
  }
785
629
 
786
- // editor
787
- const autopilot = (d.autopilot_status as string) ?? "";
788
- const renderStatus = (latest.status as string) ?? "";
789
- const failed =
790
- autopilot === "failed" ||
791
- autopilot === "cancelled" ||
792
- renderStatus === "failed";
793
- if (!failed) return "start";
794
- return `failed:${autopilot}:${d.autopilot_error_step ?? ""}:${renderSig}`;
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
+ }
689
+
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
+ );
695
+ }
696
+
697
+ return summary.run_id;
795
698
  }
796
699
 
797
700
  /** Stable idempotency key for a logical operation, so a retried call is safe. */
@@ -817,6 +720,7 @@ export function idemKey(...parts: string[]): string {
817
720
  /** make_video's editor draft-create key (`POST /editor`). */
818
721
  export interface MakeVideoEditorCreateArgs {
819
722
  prompt: string;
723
+ segments_count?: number;
820
724
  product_subject?: string;
821
725
  project_intent?: string;
822
726
  language?: string;
@@ -831,6 +735,7 @@ export function makeVideoEditorCreateKey(a: MakeVideoEditorCreateArgs): string {
831
735
  return idemKey(
832
736
  "make-editor",
833
737
  a.prompt,
738
+ a.segments_count === undefined ? "" : String(a.segments_count),
834
739
  a.product_subject ?? "",
835
740
  a.project_intent ?? "",
836
741
  a.language ?? "en",
@@ -857,6 +762,7 @@ export function makeVideoAutopilotKey(
857
762
  "autopilot",
858
763
  slug,
859
764
  a.prompt,
765
+ a.segments_count === undefined ? "" : String(a.segments_count),
860
766
  a.product_subject ?? "",
861
767
  a.project_intent ?? "",
862
768
  a.language ?? "en",
@@ -872,6 +778,7 @@ export function makeVideoAutopilotKey(
872
778
  /** create_editor_ad's draft-create and autopilot-start key argument set. */
873
779
  export interface EditorAdKeyArgs {
874
780
  product_prompt?: string;
781
+ segments_count?: number;
875
782
  product_subject?: string;
876
783
  project_intent?: string;
877
784
  language?: string;
@@ -906,6 +813,7 @@ export function editorAdCreateKey(a: EditorAdKeyArgs): string {
906
813
  return idemKey(
907
814
  "create-editor",
908
815
  a.product_prompt ?? "",
816
+ a.segments_count === undefined ? "" : String(a.segments_count),
909
817
  a.product_subject ?? "",
910
818
  a.project_intent ?? "",
911
819
  a.language ?? "en",
@@ -931,18 +839,22 @@ export function editorAdCreateKey(a: EditorAdKeyArgs): string {
931
839
 
932
840
  /**
933
841
  * create_editor_ad's autopilot-start key (`POST /editor/:slug/autopilot`). Folds
934
- * the create params AND the approved max_credits cap. There is no startState
935
- * discriminator (this tool starts right after create, so there is no prior
936
- * terminal state to guard against), so max_credits is what lets a re-call with a
937
- * RAISED cap after a failed/over-cap launch mint a fresh key and re-enqueue
938
- * rather than replay the server's 24h-cached dead 202. Self-consistent per-tool
939
- * 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.
940
847
  */
941
- export function editorAdAutopilotKey(slug: string, a: EditorAdKeyArgs): string {
848
+ export function editorAdAutopilotKey(
849
+ slug: string,
850
+ a: EditorAdKeyArgs,
851
+ startState: string,
852
+ ): string {
942
853
  return idemKey(
943
854
  "autopilot",
944
855
  slug,
945
856
  a.product_prompt ?? "",
857
+ a.segments_count === undefined ? "" : String(a.segments_count),
946
858
  a.product_subject ?? "",
947
859
  a.project_intent ?? "",
948
860
  a.language ?? "en",
@@ -961,6 +873,7 @@ export function editorAdAutopilotKey(slug: string, a: EditorAdKeyArgs): string {
961
873
  ? ""
962
874
  : String(a.logo_duration_seconds),
963
875
  a.max_credits === undefined ? "" : String(a.max_credits),
876
+ startState,
964
877
  );
965
878
  }
966
879