@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/README.md +102 -45
- package/dist/index.js +13130 -7921
- package/package.json +12 -6
- package/server.json +56 -0
- package/skill/hubfluencer-create/SKILL.md +169 -0
- package/skill/hubfluencer-create/references/full-guide.md +228 -0
- package/src/client.ts +2 -2
- package/src/core.ts +303 -431
- package/src/doctor.ts +96 -0
- package/src/generation-summary.ts +262 -0
- package/src/index.ts +1003 -278
- package/src/output-schemas.ts +85 -12
- package/src/polling.ts +18 -5
- package/src/skill-installer.ts +100 -0
- package/src/tool-profile.ts +93 -0
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
|
|
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
|
|
332
|
-
const have = body.available_credits
|
|
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 =
|
|
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
|
-
|
|
354
|
-
const
|
|
355
|
-
|
|
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
|
|
360
|
-
ready
|
|
530
|
+
stage: editingRequired ? "editing_required" : summary.phase,
|
|
531
|
+
terminal,
|
|
532
|
+
ready,
|
|
361
533
|
video_url: videoUrl,
|
|
362
|
-
error:
|
|
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
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
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
|
-
|
|
734
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
799
|
-
*
|
|
800
|
-
*
|
|
801
|
-
*
|
|
802
|
-
*
|
|
803
|
-
*
|
|
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
|
-
*
|
|
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
|
-
|
|
812
|
-
|
|
813
|
-
|
|
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
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
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
|
-
|
|
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
|
|
975
|
-
* discriminator
|
|
976
|
-
*
|
|
977
|
-
*
|
|
978
|
-
*
|
|
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(
|
|
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
|
|