FN-8289: add feature-video review artifacts
Add gated local feature-video capture to completed user-facing deliverables. - Capture loopback scenario WebM recordings through the existing artifact registry. - Keep recording failures non-blocking and cover gated, unsafe, and failed capture paths. - Document the scenario contract and package the Playwright runtime dependency. Files changed: .changeset/fn-8289-feature-video.md | 7 + docs/workflow-steps.md | 8 + packages/cli/package.json | 3 +- packages/cli/tsup.config.ts | 7 + packages/engine/package.json | 3 +- .../__tests__/executor-review-artifacts.test.ts | 44 ++++ packages/engine/src/executor.ts | 33 +++ .../src/review-artifacts/feature-video.test.ts | 75 +++++++ .../engine/src/review-artifacts/feature-video.ts | 226 +++++++++++++++++++++ packages/engine/src/review-artifacts/index.ts | 10 + pnpm-lock.yaml | 6 + 11 files changed, 420 insertions(+), 2 deletions(-) Fusion-Task-Id: FN-8289 Fusion-Task-Lineage: 8a34675a-2417-4669-a14a-b74c4aa99331 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-8289-feature-video.md
Normal file
7
.changeset/fn-8289-feature-video.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": minor
|
||||||
|
---
|
||||||
|
|
||||||
|
summary: Auto-generate a short feature-video artifact for user-facing task deliverables.
|
||||||
|
category: feature
|
||||||
|
dev: Gated by the reviewArtifacts mode; best-effort playwright-core WebM capture at executor completion is linked by taskId. Loopback-only scenarios; oversize output is rejected and capture failure never fails the task.
|
||||||
@@ -932,3 +932,11 @@ Authoring surfaces:
|
|||||||

|

|
||||||
|
|
||||||
See also: [Task Management](./task-management.md) and [Settings Reference](./settings-reference.md).
|
See also: [Task Management](./task-management.md) and [Settings Reference](./settings-reference.md).
|
||||||
|
|
||||||
|
## Best-effort Feature-Video Review Artifacts
|
||||||
|
|
||||||
|
When the project or task-level [`reviewArtifacts`](./settings-reference.md) policy permits a completed task, the executor attempts a short feature-video immediately before its clean handoff to review. It is an optional deliverable: browser discovery, navigation, recording, and artifact-registration failures are logged and never block completion or review handoff.
|
||||||
|
|
||||||
|
The MVP requires a persisted `review-artifact-scenario` task document containing JSON such as `{ "baseUrl": "http://127.0.0.1:5173", "targetRoute": "/settings" }`. The URL must be `http` or `https` on `127.0.0.1`, `localhost`, or `::1`; missing, malformed, remote, or unreachable scenarios are skipped. Fusion does not start or manage this server. An optional `flowScript` identifier is accepted for future registered flows; unknown identifiers use the default navigate-and-settle recording.
|
||||||
|
|
||||||
|
Capture uses local Chromium through `playwright-core` and records WebM. Recording is capped at 15 seconds (normally three seconds); output over the size cap is rejected without artifact registration rather than trimmed or re-encoded. A successful recording is registered through the normal artifact registry as `type="video"`, `mimeType="video/webm"`, and linked to its task, so existing review-artifact galleries display it.
|
||||||
|
|||||||
@@ -79,7 +79,8 @@
|
|||||||
"node-pty": "npm:@homebridge/node-pty-prebuilt-multiarch@^0.13.1",
|
"node-pty": "npm:@homebridge/node-pty-prebuilt-multiarch@^0.13.1",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-i18next": "^17.0.8",
|
"react-i18next": "^17.0.8",
|
||||||
"ws": "^8.18.0"
|
"ws": "^8.18.0",
|
||||||
|
"playwright-core": "^1.60.0"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"typebox": "*"
|
"typebox": "*"
|
||||||
|
|||||||
@@ -412,6 +412,13 @@ const cliBuildConfig = {
|
|||||||
"cpu-features",
|
"cpu-features",
|
||||||
"embedded-postgres",
|
"embedded-postgres",
|
||||||
/^@embedded-postgres\//,
|
/^@embedded-postgres\//,
|
||||||
|
/*
|
||||||
|
FNXC:ReviewArtifacts 2026-07-19-10:00:
|
||||||
|
The engine lazy-loads playwright-core only for a gated local feature-video.
|
||||||
|
Keep it external because Playwright has optional Chromium BiDi internals that
|
||||||
|
esbuild cannot resolve, while the published CLI installs this direct runtime dep.
|
||||||
|
*/
|
||||||
|
"playwright-core",
|
||||||
],
|
],
|
||||||
splitting: false,
|
splitting: false,
|
||||||
// Keep clean disabled so the dedicated plugin-sdk tsup config can emit into
|
// Keep clean disabled so the dedicated plugin-sdk tsup config can emit into
|
||||||
|
|||||||
@@ -47,7 +47,8 @@
|
|||||||
"esbuild": "^0.25.12",
|
"esbuild": "^0.25.12",
|
||||||
"node-pty": "npm:@homebridge/node-pty-prebuilt-multiarch@^0.13.1",
|
"node-pty": "npm:@homebridge/node-pty-prebuilt-multiarch@^0.13.1",
|
||||||
"proper-lockfile": "^4.1.2",
|
"proper-lockfile": "^4.1.2",
|
||||||
"typebox": "^1.0.0"
|
"typebox": "^1.0.0",
|
||||||
|
"playwright-core": "^1.60.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^25.5.0",
|
"@types/node": "^25.5.0",
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { mkdtemp, writeFile } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { generateFeatureVideo, type FeatureVideoBrowserClient } from "../review-artifacts/feature-video.js";
|
||||||
|
import { TaskExecutor } from "../executor.js";
|
||||||
|
|
||||||
|
const cleanup: string[] = [];
|
||||||
|
afterEach(async () => { await Promise.all(cleanup.splice(0).map((dir) => import("node:fs/promises").then(({ rm }) => rm(dir, { recursive: true, force: true })))); });
|
||||||
|
|
||||||
|
describe("TaskExecutor feature-video completion handoff", () => {
|
||||||
|
const task = { id: "FN-video", description: "user-facing", column: "in-progress", dependencies: [], steps: [], currentStep: 0 } as any;
|
||||||
|
|
||||||
|
function makeStore() {
|
||||||
|
return {
|
||||||
|
on: vi.fn(), getSettings: vi.fn().mockResolvedValue({ reviewArtifacts: "on" }), getTask: vi.fn().mockResolvedValue({ ...task, prompt: "**Review Artifacts:** on" }),
|
||||||
|
getTaskDocument: vi.fn().mockResolvedValue({ content: JSON.stringify({ baseUrl: "http://127.0.0.1:5173", targetRoute: "/" }) }),
|
||||||
|
registerArtifact: vi.fn().mockResolvedValue({ id: "video-1" }),
|
||||||
|
handoffToReview: vi.fn().mockResolvedValue({ ...task, column: "in-review" }),
|
||||||
|
} as any;
|
||||||
|
}
|
||||||
|
|
||||||
|
it("runs the injectable capture seam before handing completed work to review", async () => {
|
||||||
|
const store = makeStore();
|
||||||
|
const dir = await mkdtemp(join(tmpdir(), "executor-video-test-"));
|
||||||
|
cleanup.push(dir);
|
||||||
|
const videoPath = join(dir, "recording.webm");
|
||||||
|
await writeFile(videoPath, "webm");
|
||||||
|
const client: FeatureVideoBrowserClient = { launch: vi.fn().mockResolvedValue({ newContext: async () => ({ newPage: async () => ({ goto: async () => undefined, video: () => ({ path: async () => videoPath }) }), close: async () => undefined }), close: async () => undefined }) };
|
||||||
|
const capture = vi.fn((options) => generateFeatureVideo({ ...options, client, sleep: async () => undefined }));
|
||||||
|
const executor = new TaskExecutor(store, "/repo", { reviewArtifactGenerator: capture });
|
||||||
|
await (executor as any).handoffTaskToReview(task, "fn_task_done");
|
||||||
|
expect(capture).toHaveBeenCalledWith(expect.objectContaining({ task: expect.objectContaining({ id: task.id }), settings: { reviewArtifacts: "on" } }));
|
||||||
|
expect(store.registerArtifact).toHaveBeenCalledWith(expect.objectContaining({ type: "video", taskId: task.id }));
|
||||||
|
expect(store.handoffToReview).toHaveBeenCalledWith(task.id, expect.any(Object));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves completion handoff when capture rejects", async () => {
|
||||||
|
const store = makeStore();
|
||||||
|
const executor = new TaskExecutor(store, "/repo", { reviewArtifactGenerator: vi.fn().mockRejectedValue(new Error("browser crashed")) });
|
||||||
|
await expect((executor as any).handoffTaskToReview(task, "fn_task_done")).resolves.toMatchObject({ column: "in-review" });
|
||||||
|
expect(store.handoffToReview).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -17,6 +17,7 @@ import { getUnmetSchedulingDependencies } from "./scheduler.js";
|
|||||||
import { RetryStormError, serializeRetryStormError, isExperimentalFeatureEnabled, evaluateCompletedPromotionFailureProvenance, evaluateSkipBypassTaint, resolveWorkflowIrForTask, resolveColumnAgentBinding, resolveEffectiveAgent, instanceNodeId, getWorkflowExtensionRegistry, getBuiltinWorkflow, parseNoOpCompletionMarker, allowsAutoMergeProcessing, resolveEffectiveAutoMerge, isLiveSharedBranchGroupMemberIntegration, resolveMaxAutoMergeRetries, resolveMaxConsecutiveToolFailureRetries, resolveConsecutiveToolFailureRetryBackoffMs, resolveConsecutiveToolFailureThreshold, resolveExecutorEscalationTarget, resolveOptionalStepRevisionBudget, resolveOptionalReviewRevisionBudget, COMPLETION_SUMMARY_NODE_ID, upsertWorkflowStepResult, AWAITING_APPROVAL_PAUSE_REASON, THINKING_LEVELS, AgentStore, resolveExecutorFallbackModel } from "@fusion/core";
|
import { RetryStormError, serializeRetryStormError, isExperimentalFeatureEnabled, evaluateCompletedPromotionFailureProvenance, evaluateSkipBypassTaint, resolveWorkflowIrForTask, resolveColumnAgentBinding, resolveEffectiveAgent, instanceNodeId, getWorkflowExtensionRegistry, getBuiltinWorkflow, parseNoOpCompletionMarker, allowsAutoMergeProcessing, resolveEffectiveAutoMerge, isLiveSharedBranchGroupMemberIntegration, resolveMaxAutoMergeRetries, resolveMaxConsecutiveToolFailureRetries, resolveConsecutiveToolFailureRetryBackoffMs, resolveConsecutiveToolFailureThreshold, resolveExecutorEscalationTarget, resolveOptionalStepRevisionBudget, resolveOptionalReviewRevisionBudget, COMPLETION_SUMMARY_NODE_ID, upsertWorkflowStepResult, AWAITING_APPROVAL_PAUSE_REASON, THINKING_LEVELS, AgentStore, resolveExecutorFallbackModel } from "@fusion/core";
|
||||||
import { finalizeProvenAutoMergeTask } from "./auto-merge-finalization.js";
|
import { finalizeProvenAutoMergeTask } from "./auto-merge-finalization.js";
|
||||||
import { mergeEffectiveSettings } from "./effective-settings.js";
|
import { mergeEffectiveSettings } from "./effective-settings.js";
|
||||||
|
import { generateFeatureVideo, type GenerateFeatureVideoOptions } from "./review-artifacts/feature-video.js";
|
||||||
import { moveTaskToReplanColumn, resolveReplanTargetColumn } from "./replan-target.js";
|
import { moveTaskToReplanColumn, resolveReplanTargetColumn } from "./replan-target.js";
|
||||||
import type { TaskStep, WorkflowIr, WorkflowFieldDefinition, WorkflowColumnAgent, EffectiveAgentInput, WorkflowWorkEngineDispatchResult } from "@fusion/core";
|
import type { TaskStep, WorkflowIr, WorkflowFieldDefinition, WorkflowColumnAgent, EffectiveAgentInput, WorkflowWorkEngineDispatchResult } from "@fusion/core";
|
||||||
import {
|
import {
|
||||||
@@ -1634,6 +1635,8 @@ export interface TaskExecutorOptions {
|
|||||||
onStart?: (task: Task, worktreePath: string) => void;
|
onStart?: (task: Task, worktreePath: string) => void;
|
||||||
onComplete?: (task: Task) => void;
|
onComplete?: (task: Task) => void;
|
||||||
onError?: (task: Task, error: Error) => void;
|
onError?: (task: Task, error: Error) => void;
|
||||||
|
/** Testable, best-effort completion-deliverable seam; production uses generateFeatureVideo. */
|
||||||
|
reviewArtifactGenerator?: (options: GenerateFeatureVideoOptions) => Promise<import("./review-artifacts/feature-video.js").FeatureVideoResult>;
|
||||||
/** Optional runtime-owned dispatch seam that lets a flag-gated workflow
|
/** Optional runtime-owned dispatch seam that lets a flag-gated workflow
|
||||||
* interpreter own the authoritative lifecycle for default coding tasks.
|
* interpreter own the authoritative lifecycle for default coding tasks.
|
||||||
* Return true when the task was fully handled and legacy execute() should stop. */
|
* Return true when the task was fully handled and legacy execute() should stop. */
|
||||||
@@ -2235,6 +2238,7 @@ export class TaskExecutor {
|
|||||||
*/
|
*/
|
||||||
private async handoffTaskToReview(task: Task, reason: string, runId = this.getRunContextFor(task.id)?.runId): Promise<Task> {
|
private async handoffTaskToReview(task: Task, reason: string, runId = this.getRunContextFor(task.id)?.runId): Promise<Task> {
|
||||||
const agentId = this.getRunContextFor(task.id)?.agentId;
|
const agentId = this.getRunContextFor(task.id)?.agentId;
|
||||||
|
await this.generateCompletionFeatureVideo(task);
|
||||||
if (reason.startsWith("workflow-")) {
|
if (reason.startsWith("workflow-")) {
|
||||||
await ensureWorkflowCompletionSummary(this.store, task as TaskDetail, {
|
await ensureWorkflowCompletionSummary(this.store, task as TaskDetail, {
|
||||||
reason,
|
reason,
|
||||||
@@ -2269,6 +2273,35 @@ export class TaskExecutor {
|
|||||||
return handedOff;
|
return handedOff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
FNXC:ReviewArtifacts 2026-07-19-10:00:
|
||||||
|
A successful executor handoff may offer reviewers a short local feature-video, but
|
||||||
|
capture is strictly best-effort. Bound and swallow this optional work before the
|
||||||
|
review transition so browser, scenario, and artifact failures never delay or fail it.
|
||||||
|
*/
|
||||||
|
private async generateCompletionFeatureVideo(task: Task): Promise<void> {
|
||||||
|
try {
|
||||||
|
const [settings, detail] = await Promise.all([this.store.getSettings(), this.store.getTask(task.id)]);
|
||||||
|
const generator = this.options.reviewArtifactGenerator ?? generateFeatureVideo;
|
||||||
|
const result = await this.awaitFeatureVideoBounded(generator({ store: this.store, task: detail ?? task, settings }));
|
||||||
|
executorLog.log(`${task.id}: feature-video ${result.status}${"reason" in result ? ` (${result.reason})` : ""}`);
|
||||||
|
} catch (error) {
|
||||||
|
executorLog.warn(`${task.id}: feature-video capture ignored: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async awaitFeatureVideoBounded(result: Promise<import("./review-artifacts/feature-video.js").FeatureVideoResult>): Promise<import("./review-artifacts/feature-video.js").FeatureVideoResult> {
|
||||||
|
let timeout: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
try {
|
||||||
|
return await Promise.race([
|
||||||
|
result,
|
||||||
|
new Promise<never>((_, reject) => { timeout = setTimeout(() => reject(new Error("feature-video timeout")), 20_000); }),
|
||||||
|
]);
|
||||||
|
} finally {
|
||||||
|
if (timeout) clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private getModelRegistry(): Promise<ModelRegistry> {
|
private getModelRegistry(): Promise<ModelRegistry> {
|
||||||
if (!this._modelRegistry) {
|
if (!this._modelRegistry) {
|
||||||
const authStorage = createFusionAuthStorage();
|
const authStorage = createFusionAuthStorage();
|
||||||
|
|||||||
75
packages/engine/src/review-artifacts/feature-video.test.ts
Normal file
75
packages/engine/src/review-artifacts/feature-video.test.ts
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import { mkdtemp, writeFile } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { generateFeatureVideo, type FeatureVideoBrowserClient } from "./feature-video.js";
|
||||||
|
|
||||||
|
const task = { id: "FN-8289", title: "Feature demo", prompt: "**Review Artifacts:** on" } as const;
|
||||||
|
const scenario = { id: "doc", taskId: task.id, key: "review-artifact-scenario", content: JSON.stringify({ baseUrl: "http://127.0.0.1:5173", targetRoute: "/settings" }), revision: 1, author: "agent", createdAt: "", updatedAt: "" };
|
||||||
|
const tempDirs: string[] = [];
|
||||||
|
afterEach(async () => { await Promise.all(tempDirs.splice(0).map((dir) => import("node:fs/promises").then(({ rm }) => rm(dir, { recursive: true, force: true })))); });
|
||||||
|
|
||||||
|
async function videoClient(bytes = Buffer.from("webm")): Promise<FeatureVideoBrowserClient> {
|
||||||
|
const dir = await mkdtemp(join(tmpdir(), "feature-video-test-"));
|
||||||
|
tempDirs.push(dir);
|
||||||
|
const path = join(dir, "video.webm");
|
||||||
|
await writeFile(path, bytes);
|
||||||
|
return {
|
||||||
|
launch: vi.fn().mockResolvedValue({
|
||||||
|
newContext: vi.fn().mockResolvedValue({
|
||||||
|
newPage: vi.fn().mockResolvedValue({ goto: vi.fn().mockResolvedValue(undefined), video: () => ({ path: async () => path }) }),
|
||||||
|
close: vi.fn().mockResolvedValue(undefined),
|
||||||
|
}),
|
||||||
|
close: vi.fn().mockResolvedValue(undefined),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function store(document: typeof scenario | null = scenario) {
|
||||||
|
return {
|
||||||
|
getTaskDocument: vi.fn().mockResolvedValue(document),
|
||||||
|
registerArtifact: vi.fn().mockResolvedValue({ id: "artifact-1" }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("generateFeatureVideo", () => {
|
||||||
|
it("registers a WebM video linked to its task through the existing registry", async () => {
|
||||||
|
const artifactStore = store();
|
||||||
|
const result = await generateFeatureVideo({ store: artifactStore, task, settings: { reviewArtifacts: "on" }, client: await videoClient(), sleep: async () => undefined });
|
||||||
|
expect(result).toEqual({ status: "captured", artifactId: "artifact-1" });
|
||||||
|
expect(artifactStore.registerArtifact).toHaveBeenCalledWith(expect.objectContaining({ type: "video", taskId: task.id, mimeType: "video/webm", data: Buffer.from("webm") }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("short-circuits before reading a scenario or browser when policy is off", async () => {
|
||||||
|
const artifactStore = store();
|
||||||
|
const client = await videoClient();
|
||||||
|
await expect(generateFeatureVideo({ store: artifactStore, task: { ...task, prompt: undefined }, settings: { reviewArtifacts: "off" }, client })).resolves.toEqual({ status: "skipped", reason: "gated-off" });
|
||||||
|
expect(artifactStore.getTaskDocument).not.toHaveBeenCalled();
|
||||||
|
expect(client.launch).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
[null, "no-scenario"],
|
||||||
|
[{ ...scenario, content: "not json" }, "no-scenario"],
|
||||||
|
[{ ...scenario, content: JSON.stringify({ baseUrl: "https://example.com", targetRoute: "/" }) }, "scenario-url-not-local"],
|
||||||
|
])("skips missing or unsafe scenario contracts (%s)", async (document, reason) => {
|
||||||
|
const artifactStore = store(document as typeof scenario | null);
|
||||||
|
await expect(generateFeatureVideo({ store: artifactStore, task, settings: { reviewArtifacts: "on" }, client: await videoClient() })).resolves.toEqual({ status: "skipped", reason });
|
||||||
|
expect(artifactStore.registerArtifact).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("swallows browser and navigation failures without registering partial artifacts", async () => {
|
||||||
|
const artifactStore = store();
|
||||||
|
const unavailable: FeatureVideoBrowserClient = { launch: vi.fn().mockRejectedValue(new Error("missing chromium")) };
|
||||||
|
await expect(generateFeatureVideo({ store: artifactStore, task, settings: { reviewArtifacts: "on" }, client: unavailable })).resolves.toEqual({ status: "skipped", reason: "browser-unavailable" });
|
||||||
|
const navigation: FeatureVideoBrowserClient = { launch: vi.fn().mockResolvedValue({ newContext: async () => ({ newPage: async () => ({ goto: async () => { throw new Error("offline"); }, video: () => null }), close: async () => undefined }), close: async () => undefined }) };
|
||||||
|
await expect(generateFeatureVideo({ store: artifactStore, task, settings: { reviewArtifacts: "on" }, client: navigation })).resolves.toEqual({ status: "skipped", reason: "navigation-failed" });
|
||||||
|
expect(artifactStore.registerArtifact).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects oversize output without registration", async () => {
|
||||||
|
const artifactStore = store();
|
||||||
|
await expect(generateFeatureVideo({ store: artifactStore, task, settings: { reviewArtifacts: "on" }, client: await videoClient(Buffer.alloc(11)), maxBytes: 10, sleep: async () => undefined })).resolves.toEqual({ status: "failed", reason: "size-cap-exceeded" });
|
||||||
|
expect(artifactStore.registerArtifact).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
226
packages/engine/src/review-artifacts/feature-video.ts
Normal file
226
packages/engine/src/review-artifacts/feature-video.ts
Normal file
@@ -0,0 +1,226 @@
|
|||||||
|
import { access, mkdtemp, readFile, rm } from "node:fs/promises";
|
||||||
|
import { constants } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import {
|
||||||
|
isReviewArtifactGenerationEligible,
|
||||||
|
type Artifact,
|
||||||
|
type ProjectSettings,
|
||||||
|
type Task,
|
||||||
|
type TaskDocument,
|
||||||
|
} from "@fusion/core";
|
||||||
|
|
||||||
|
const SCENARIO_DOCUMENT_KEY = "review-artifact-scenario";
|
||||||
|
const MAX_DURATION_MS = 15_000;
|
||||||
|
const DEFAULT_DURATION_MS = 3_000;
|
||||||
|
const DEFAULT_MAX_BYTES = 25 * 1024 * 1024;
|
||||||
|
const NAVIGATION_TIMEOUT_MS = 10_000;
|
||||||
|
|
||||||
|
export interface FeatureVideoPage {
|
||||||
|
goto(url: string, options: { timeout: number; waitUntil: "load" }): Promise<unknown>;
|
||||||
|
video(): { path(): Promise<string> } | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FeatureVideoContext {
|
||||||
|
newPage(): Promise<FeatureVideoPage>;
|
||||||
|
close(): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FeatureVideoBrowser {
|
||||||
|
newContext(options: { recordVideo: { dir: string; size: { width: number; height: number } } }): Promise<FeatureVideoContext>;
|
||||||
|
close(): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Injectable seam so unit tests never launch a real browser. */
|
||||||
|
export interface FeatureVideoBrowserClient {
|
||||||
|
launch(options: { executablePath?: string; headless: boolean }): Promise<FeatureVideoBrowser>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FeatureVideoStore {
|
||||||
|
getTaskDocument(taskId: string, key: string): Promise<TaskDocument | null>;
|
||||||
|
registerArtifact(input: {
|
||||||
|
type: "video";
|
||||||
|
taskId: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
mimeType: "video/webm";
|
||||||
|
data: Buffer;
|
||||||
|
authorId: string;
|
||||||
|
authorType: "system";
|
||||||
|
}): Promise<Artifact>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FeatureVideoResult =
|
||||||
|
| { status: "skipped"; reason: "gated-off" | "no-scenario" | "scenario-url-not-local" | "browser-unavailable" | "navigation-failed" }
|
||||||
|
| { status: "failed"; reason: "size-cap-exceeded" | "capture-failed" }
|
||||||
|
| { status: "captured"; artifactId: string };
|
||||||
|
|
||||||
|
export interface GenerateFeatureVideoOptions {
|
||||||
|
store: FeatureVideoStore;
|
||||||
|
task: Pick<Task, "id" | "title"> & { prompt?: string };
|
||||||
|
settings: Pick<ProjectSettings, "reviewArtifacts">;
|
||||||
|
client?: FeatureVideoBrowserClient;
|
||||||
|
executablePath?: string;
|
||||||
|
durationMs?: number;
|
||||||
|
maxBytes?: number;
|
||||||
|
sleep?: (ms: number) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Scenario {
|
||||||
|
baseUrl: string;
|
||||||
|
targetRoute: string;
|
||||||
|
flowScript?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
FNXC:ReviewArtifacts 2026-07-19-10:00:
|
||||||
|
Feature-video generation is a gated, best-effort completion deliverable. It uses the
|
||||||
|
existing artifact registry (not a parallel media store), accepts only loopback scenario
|
||||||
|
URLs, and returns a result for every failure so recording can never fail task completion.
|
||||||
|
*/
|
||||||
|
export function shouldGenerateReviewArtifacts(
|
||||||
|
task: Pick<Task, "id"> & { prompt?: string },
|
||||||
|
settings: Pick<ProjectSettings, "reviewArtifacts">,
|
||||||
|
): boolean {
|
||||||
|
return isReviewArtifactGenerationEligible(settings, task.prompt);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Captures a short WebM from the persisted local scenario contract and registers its
|
||||||
|
* bytes through TaskStore. This boundary intentionally swallows all capture failures.
|
||||||
|
*/
|
||||||
|
export async function generateFeatureVideo(options: GenerateFeatureVideoOptions): Promise<FeatureVideoResult> {
|
||||||
|
if (!shouldGenerateReviewArtifacts(options.task, options.settings)) {
|
||||||
|
return { status: "skipped", reason: "gated-off" };
|
||||||
|
}
|
||||||
|
|
||||||
|
let scenarioDocument: TaskDocument | null;
|
||||||
|
try {
|
||||||
|
scenarioDocument = await options.store.getTaskDocument(options.task.id, SCENARIO_DOCUMENT_KEY);
|
||||||
|
} catch {
|
||||||
|
return { status: "skipped", reason: "no-scenario" };
|
||||||
|
}
|
||||||
|
const scenario = parseScenario(scenarioDocument?.content);
|
||||||
|
if (!scenario) return { status: "skipped", reason: "no-scenario" };
|
||||||
|
const url = resolveLoopbackScenarioUrl(scenario);
|
||||||
|
if (!url) return { status: "skipped", reason: "scenario-url-not-local" };
|
||||||
|
|
||||||
|
const executablePath = options.client ? options.executablePath : await probeBrowserExecutable(options.executablePath);
|
||||||
|
if (!options.client && !executablePath) return { status: "skipped", reason: "browser-unavailable" };
|
||||||
|
const client = options.client ?? await createPlaywrightFeatureVideoClient();
|
||||||
|
if (!client) return { status: "skipped", reason: "browser-unavailable" };
|
||||||
|
|
||||||
|
let recordingDir: string | undefined;
|
||||||
|
let browser: FeatureVideoBrowser | undefined;
|
||||||
|
let context: FeatureVideoContext | undefined;
|
||||||
|
try {
|
||||||
|
recordingDir = await mkdtemp(join(tmpdir(), "fusion-feature-video-"));
|
||||||
|
try {
|
||||||
|
browser = await client.launch({ executablePath, headless: true });
|
||||||
|
} catch {
|
||||||
|
return { status: "skipped", reason: "browser-unavailable" };
|
||||||
|
}
|
||||||
|
context = await browser.newContext({ recordVideo: { dir: recordingDir, size: { width: 1280, height: 720 } } });
|
||||||
|
const page = await context.newPage();
|
||||||
|
try {
|
||||||
|
await page.goto(url, { timeout: NAVIGATION_TIMEOUT_MS, waitUntil: "load" });
|
||||||
|
} catch {
|
||||||
|
return { status: "skipped", reason: "navigation-failed" };
|
||||||
|
}
|
||||||
|
const video = page.video();
|
||||||
|
if (!video) return { status: "failed", reason: "capture-failed" };
|
||||||
|
await (options.sleep ?? defaultSleep)(Math.min(Math.max(options.durationMs ?? DEFAULT_DURATION_MS, 0), MAX_DURATION_MS));
|
||||||
|
await context.close();
|
||||||
|
context = undefined;
|
||||||
|
const data = await readFile(await video.path());
|
||||||
|
if (data.byteLength > (options.maxBytes ?? DEFAULT_MAX_BYTES)) {
|
||||||
|
return { status: "failed", reason: "size-cap-exceeded" };
|
||||||
|
}
|
||||||
|
const artifact = await options.store.registerArtifact({
|
||||||
|
type: "video",
|
||||||
|
taskId: options.task.id,
|
||||||
|
title: `Feature video: ${options.task.title ?? options.task.id}`,
|
||||||
|
description: `Best-effort feature recording for ${scenario.targetRoute}.`,
|
||||||
|
mimeType: "video/webm",
|
||||||
|
data,
|
||||||
|
authorId: "executor",
|
||||||
|
authorType: "system",
|
||||||
|
});
|
||||||
|
return { status: "captured", artifactId: artifact.id };
|
||||||
|
} catch {
|
||||||
|
return { status: "failed", reason: "capture-failed" };
|
||||||
|
} finally {
|
||||||
|
await safeClose(context);
|
||||||
|
await safeClose(browser);
|
||||||
|
if (recordingDir) await rm(recordingDir, { recursive: true, force: true }).catch(() => undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseScenario(content: string | undefined): Scenario | undefined {
|
||||||
|
if (!content) return undefined;
|
||||||
|
try {
|
||||||
|
const value = JSON.parse(content) as Partial<Scenario>;
|
||||||
|
return typeof value.baseUrl === "string" && typeof value.targetRoute === "string" && value.targetRoute.startsWith("/")
|
||||||
|
? { baseUrl: value.baseUrl, targetRoute: value.targetRoute, ...(typeof value.flowScript === "string" ? { flowScript: value.flowScript } : {}) }
|
||||||
|
: undefined;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveLoopbackScenarioUrl(scenario: Scenario): string | undefined {
|
||||||
|
try {
|
||||||
|
const base = new URL(scenario.baseUrl);
|
||||||
|
if (!/^https?:$/.test(base.protocol) || !["127.0.0.1", "localhost", "::1"].includes(base.hostname)) return undefined;
|
||||||
|
return new URL(scenario.targetRoute, base).toString();
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
FNXC:ReviewArtifacts 2026-07-19-10:00:
|
||||||
|
playwright-core does not download Chromium. Probe only known local executable locations
|
||||||
|
before launch so an unavailable browser is a clean skip, never a completion failure.
|
||||||
|
*/
|
||||||
|
async function probeBrowserExecutable(explicit?: string): Promise<string | undefined> {
|
||||||
|
const candidates = [
|
||||||
|
explicit,
|
||||||
|
process.env.FUSION_BROWSER_EXECUTABLE,
|
||||||
|
process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH,
|
||||||
|
process.env.CHROME_PATH,
|
||||||
|
...(process.platform === "darwin" ? [
|
||||||
|
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
||||||
|
"/Applications/Chromium.app/Contents/MacOS/Chromium",
|
||||||
|
"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
|
||||||
|
] : process.platform === "win32" ? [] : [
|
||||||
|
"/usr/bin/google-chrome", "/usr/bin/google-chrome-stable", "/usr/bin/chromium", "/usr/bin/chromium-browser",
|
||||||
|
]),
|
||||||
|
].filter((candidate): candidate is string => Boolean(candidate?.trim()));
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
try {
|
||||||
|
await access(candidate, constants.X_OK);
|
||||||
|
return candidate;
|
||||||
|
} catch {
|
||||||
|
// Continue with the next local candidate; no subprocess or download is used.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createPlaywrightFeatureVideoClient(): Promise<FeatureVideoBrowserClient | undefined> {
|
||||||
|
try {
|
||||||
|
const playwright = await import("playwright-core") as unknown as { chromium: FeatureVideoBrowserClient };
|
||||||
|
return playwright.chromium;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function safeClose(value: { close(): Promise<void> } | undefined): Promise<void> {
|
||||||
|
await value?.close().catch(() => undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultSleep(ms: number): Promise<void> {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
10
packages/engine/src/review-artifacts/index.ts
Normal file
10
packages/engine/src/review-artifacts/index.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
export {
|
||||||
|
generateFeatureVideo,
|
||||||
|
shouldGenerateReviewArtifacts,
|
||||||
|
type FeatureVideoBrowser,
|
||||||
|
type FeatureVideoBrowserClient,
|
||||||
|
type FeatureVideoContext,
|
||||||
|
type FeatureVideoPage,
|
||||||
|
type FeatureVideoResult,
|
||||||
|
type GenerateFeatureVideoOptions,
|
||||||
|
} from "./feature-video.js";
|
||||||
6
pnpm-lock.yaml
generated
6
pnpm-lock.yaml
generated
@@ -92,6 +92,9 @@ importers:
|
|||||||
node-pty:
|
node-pty:
|
||||||
specifier: npm:@homebridge/node-pty-prebuilt-multiarch@^0.13.1
|
specifier: npm:@homebridge/node-pty-prebuilt-multiarch@^0.13.1
|
||||||
version: '@homebridge/node-pty-prebuilt-multiarch@0.13.1'
|
version: '@homebridge/node-pty-prebuilt-multiarch@0.13.1'
|
||||||
|
playwright-core:
|
||||||
|
specifier: ^1.60.0
|
||||||
|
version: 1.60.0
|
||||||
react:
|
react:
|
||||||
specifier: ^19.2.0
|
specifier: ^19.2.0
|
||||||
version: 19.2.4
|
version: 19.2.4
|
||||||
@@ -560,6 +563,9 @@ importers:
|
|||||||
node-pty:
|
node-pty:
|
||||||
specifier: npm:@homebridge/node-pty-prebuilt-multiarch@^0.13.1
|
specifier: npm:@homebridge/node-pty-prebuilt-multiarch@^0.13.1
|
||||||
version: '@homebridge/node-pty-prebuilt-multiarch@0.13.1'
|
version: '@homebridge/node-pty-prebuilt-multiarch@0.13.1'
|
||||||
|
playwright-core:
|
||||||
|
specifier: ^1.60.0
|
||||||
|
version: 1.60.0
|
||||||
proper-lockfile:
|
proper-lockfile:
|
||||||
specifier: ^4.1.2
|
specifier: ^4.1.2
|
||||||
version: 4.1.2
|
version: 4.1.2
|
||||||
|
|||||||
Reference in New Issue
Block a user