refactor: extract reusable runtime utilities and unify execution dialog flows for preview and full runs

This commit is contained in:
Shine1i 2026-02-22 05:37:33 +01:00
commit 4cda750589
9 changed files with 748 additions and 259 deletions

View file

@ -1,4 +1,10 @@
import { type ReactElement, useMemo, useState } from "react";
import { Button } from "@/components/ui/button";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import {
Dialog,
DialogContent,
@ -7,39 +13,75 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { type ReactElement } from "react";
import { Switch } from "@/components/ui/switch";
import type { RecipeExecutionKind } from "../execution-types";
import type { RecipeRunSettings } from "../stores/recipe-executions";
import { FieldLabel } from "./shared/field-label";
type PreviewDialogProps = {
type RunDialogProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
kind: RecipeExecutionKind;
rows: number;
onRowsChange: (rows: number) => void;
settings: RecipeRunSettings;
onSettingsChange: (patch: Partial<RecipeRunSettings>) => void;
loading: boolean;
errors: string[];
summary: {
totalColumns: number;
llmColumns: number;
samplerColumns: number;
expressionColumns: number;
toolConfigs: number;
mcpProviders: number;
};
onPreview: () => void;
onRun: () => void;
container?: HTMLDivElement | null;
};
export function PreviewDialog({
function clampInt(value: number, min: number, max: number): number {
if (!Number.isFinite(value)) {
return min;
}
const next = Math.floor(value);
if (next < min) {
return min;
}
if (next > max) {
return max;
}
return next;
}
function clampFloat(value: number, min: number, max: number): number {
if (!Number.isFinite(value)) {
return min;
}
if (value < min) {
return min;
}
if (value > max) {
return max;
}
return value;
}
export function RunDialog({
open,
onOpenChange,
kind,
rows,
onRowsChange,
settings,
onSettingsChange,
loading,
errors,
summary,
onPreview,
onRun,
container,
}: PreviewDialogProps): ReactElement {
}: RunDialogProps): ReactElement {
const [advancedOpen, setAdvancedOpen] = useState(false);
const kindLabel = kind === "preview" ? "Preview" : "Full run";
const rowHint = useMemo(
() =>
kind === "preview"
? "How many sample rows to generate for a quick check."
: "How many rows to generate in total.",
[kind],
);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
@ -47,57 +89,233 @@ export function PreviewDialog({
position="absolute"
overlayPosition="absolute"
overlayClassName="bg-transparent"
className="corner-squircle sm:max-w-md"
className="corner-squircle sm:max-w-2xl"
>
<DialogHeader>
<DialogTitle>Preview data</DialogTitle>
<DialogTitle>{kindLabel} settings</DialogTitle>
<p className="text-sm text-muted-foreground">
Configure run size and performance knobs for this execution.
</p>
</DialogHeader>
<div className="grid grid-cols-2 gap-2">
<div className="rounded-xl border border-border/60 p-2">
<p className="text-[11px] uppercase text-muted-foreground">Columns</p>
<p className="text-sm font-semibold">{summary.totalColumns}</p>
<div className="grid gap-4 md:grid-cols-3">
<div className="grid gap-2">
<FieldLabel
label="Records"
htmlFor="run-rows"
hint={rowHint}
/>
<Input
id="run-rows"
type="number"
min={1}
max={200000}
value={String(rows)}
onChange={(event) => {
const parsed = Number(event.target.value);
if (!Number.isFinite(parsed)) {
return;
}
onRowsChange(clampInt(parsed, 1, 200000));
}}
/>
</div>
<div className="rounded-xl border border-border/60 p-2">
<p className="text-[11px] uppercase text-muted-foreground">LLM</p>
<p className="text-sm font-semibold">{summary.llmColumns}</p>
<div className="grid gap-2">
<FieldLabel
label="Batch size"
htmlFor="run-buffer-size"
hint="Rows handled per batch. Bigger can be faster; smaller uses less memory."
/>
<Input
id="run-buffer-size"
type="number"
min={1}
max={200000}
value={String(settings.bufferSize)}
onChange={(event) => {
const parsed = Number(event.target.value);
if (!Number.isFinite(parsed)) {
return;
}
onSettingsChange({
bufferSize: clampInt(parsed, 1, 200000),
});
}}
/>
</div>
<div className="rounded-xl border border-border/60 p-2">
<p className="text-[11px] uppercase text-muted-foreground">Samplers</p>
<p className="text-sm font-semibold">{summary.samplerColumns}</p>
</div>
<div className="rounded-xl border border-border/60 p-2">
<p className="text-[11px] uppercase text-muted-foreground">Expressions</p>
<p className="text-sm font-semibold">{summary.expressionColumns}</p>
</div>
<div className="rounded-xl border border-border/60 p-2">
<p className="text-[11px] uppercase text-muted-foreground">Tool configs</p>
<p className="text-sm font-semibold">{summary.toolConfigs}</p>
</div>
<div className="rounded-xl border border-border/60 p-2">
<p className="text-[11px] uppercase text-muted-foreground">MCP servers</p>
<p className="text-sm font-semibold">{summary.mcpProviders}</p>
<div className="grid gap-2">
<FieldLabel
label="LLM parallel"
htmlFor="run-llm-parallel"
hint="How many LLM calls run at once. Leave empty to keep each model's own setting."
/>
<Input
id="run-llm-parallel"
type="number"
min={1}
max={2048}
placeholder="Use model config"
value={settings.llmParallelRequests ?? ""}
onChange={(event) => {
const value = event.target.value.trim();
if (!value) {
onSettingsChange({ llmParallelRequests: null });
return;
}
const parsed = Number(value);
if (!Number.isFinite(parsed)) {
return;
}
onSettingsChange({
llmParallelRequests: clampInt(parsed, 1, 2048),
});
}}
/>
</div>
</div>
<div className="grid gap-2">
<FieldLabel
label="Number of records"
htmlFor="preview-rows"
hint="Target rows for preview run."
/>
<Input
id="preview-rows"
type="number"
min={1}
max={1000}
value={String(rows)}
onChange={(event) => {
const parsed = Number(event.target.value);
if (Number.isFinite(parsed) && parsed > 0) {
onRowsChange(Math.min(1000, Math.floor(parsed)));
}
}}
/>
</div>
<Collapsible open={advancedOpen} onOpenChange={setAdvancedOpen}>
<CollapsibleTrigger asChild={true}>
<button
type="button"
className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground hover:text-foreground"
>
{advancedOpen ? "Hide advanced" : "Show advanced"}
</button>
</CollapsibleTrigger>
<CollapsibleContent className="mt-3 space-y-4">
<div className="grid gap-4 md:grid-cols-2">
<div className="grid gap-2">
<FieldLabel
label="CPU workers"
htmlFor="run-non-inference-workers"
hint="Worker threads for non-LLM steps like samplers and expressions."
/>
<Input
id="run-non-inference-workers"
type="number"
min={1}
max={2048}
value={String(settings.nonInferenceWorkers)}
onChange={(event) => {
const parsed = Number(event.target.value);
if (!Number.isFinite(parsed)) {
return;
}
onSettingsChange({
nonInferenceWorkers: clampInt(parsed, 1, 2048),
});
}}
/>
</div>
<div className="grid gap-2">
<FieldLabel
label="Error window"
htmlFor="run-shutdown-window"
hint="How many attempts to observe before early-stop checks kick in."
/>
<Input
id="run-shutdown-window"
type="number"
min={1}
max={10000}
value={String(settings.shutdownErrorWindow)}
onChange={(event) => {
const parsed = Number(event.target.value);
if (!Number.isFinite(parsed)) {
return;
}
onSettingsChange({
shutdownErrorWindow: clampInt(parsed, 1, 10000),
});
}}
/>
</div>
<div className="grid gap-2">
<FieldLabel
label="Conversation restarts"
htmlFor="run-max-restarts"
hint="How many full retries to do if model output fails validation."
/>
<Input
id="run-max-restarts"
type="number"
min={0}
max={100}
value={String(settings.maxConversationRestarts)}
onChange={(event) => {
const parsed = Number(event.target.value);
if (!Number.isFinite(parsed)) {
return;
}
onSettingsChange({
maxConversationRestarts: clampInt(parsed, 0, 100),
});
}}
/>
</div>
<div className="grid gap-2">
<FieldLabel
label="Correction steps"
htmlFor="run-correction-steps"
hint="Extra in-chat fix attempts before a full retry."
/>
<Input
id="run-correction-steps"
type="number"
min={0}
max={100}
value={String(settings.maxConversationCorrectionSteps)}
onChange={(event) => {
const parsed = Number(event.target.value);
if (!Number.isFinite(parsed)) {
return;
}
onSettingsChange({
maxConversationCorrectionSteps: clampInt(parsed, 0, 100),
});
}}
/>
</div>
<div className="grid gap-2">
<FieldLabel
label="Shutdown error rate"
htmlFor="run-shutdown-rate"
hint="Stop early if failure rate passes this value. Example: 0.5 = 50%."
/>
<Input
id="run-shutdown-rate"
type="number"
min={0}
max={1}
step={0.05}
value={String(settings.shutdownErrorRate)}
onChange={(event) => {
const parsed = Number(event.target.value);
if (!Number.isFinite(parsed)) {
return;
}
onSettingsChange({
shutdownErrorRate: clampFloat(parsed, 0, 1),
});
}}
/>
</div>
<label className="flex items-center gap-3 text-sm text-foreground">
<Switch
checked={settings.disableEarlyShutdown}
onCheckedChange={(checked) =>
onSettingsChange({
disableEarlyShutdown: Boolean(checked),
})
}
/>
Disable early shutdown
</label>
</div>
</CollapsibleContent>
</Collapsible>
{errors.length > 0 && (
<div className="max-h-44 space-y-1 overflow-y-auto rounded-xl border border-destructive/30 bg-destructive/5 p-3">
<p className="text-xs font-semibold uppercase text-destructive">
@ -110,6 +328,7 @@ export function PreviewDialog({
))}
</div>
)}
<DialogFooter>
<Button
type="button"
@ -119,8 +338,8 @@ export function PreviewDialog({
>
Cancel
</Button>
<Button type="button" onClick={onPreview} disabled={loading}>
{loading ? "Running..." : "Run preview"}
<Button type="button" onClick={onRun} disabled={loading}>
{loading ? "Starting..." : `Start ${kindLabel.toLowerCase()}`}
</Button>
</DialogFooter>
</DialogContent>

View file

@ -0,0 +1,169 @@
import type { RecipeExecutionKind } from "../execution-types";
import type { RecipeRunSettings } from "../stores/recipe-executions";
import type { RecipePayload } from "../utils/payload/types";
function toPositiveInt(
value: number,
fallback: number,
min = 1,
max = Number.MAX_SAFE_INTEGER,
): number {
if (!Number.isFinite(value)) {
return fallback;
}
const next = Math.floor(value);
if (next < min) {
return min;
}
if (next > max) {
return max;
}
return next;
}
function toNonNegativeInt(
value: number,
fallback: number,
max = Number.MAX_SAFE_INTEGER,
): number {
if (!Number.isFinite(value)) {
return fallback;
}
const next = Math.floor(value);
if (next < 0) {
return 0;
}
if (next > max) {
return max;
}
return next;
}
function toRatio(value: number, fallback: number): number {
if (!Number.isFinite(value)) {
return fallback;
}
if (value < 0) {
return 0;
}
if (value > 1) {
return 1;
}
return value;
}
export function sanitizeExecutionRows(
rows: number,
kind: RecipeExecutionKind,
): number {
return toPositiveInt(rows, kind === "preview" ? 5 : 1000);
}
export function normalizeRunSettings(settings: RecipeRunSettings): RecipeRunSettings {
return {
bufferSize: toPositiveInt(settings.bufferSize, 1000, 1, 200_000),
llmParallelRequests:
typeof settings.llmParallelRequests === "number"
? toPositiveInt(settings.llmParallelRequests, 4, 1, 2048)
: null,
nonInferenceWorkers: toPositiveInt(
settings.nonInferenceWorkers,
4,
1,
2048,
),
maxConversationRestarts: toNonNegativeInt(
settings.maxConversationRestarts,
5,
100,
),
maxConversationCorrectionSteps: toNonNegativeInt(
settings.maxConversationCorrectionSteps,
0,
100,
),
disableEarlyShutdown: Boolean(settings.disableEarlyShutdown),
shutdownErrorRate: toRatio(settings.shutdownErrorRate, 0.5),
shutdownErrorWindow: toPositiveInt(settings.shutdownErrorWindow, 10, 1, 10_000),
};
}
function buildRunConfigPayload(
settings: RecipeRunSettings,
): Record<string, unknown> {
return {
// biome-ignore lint/style/useNamingConvention: backend schema
buffer_size: settings.bufferSize,
// biome-ignore lint/style/useNamingConvention: backend schema
non_inference_max_parallel_workers: settings.nonInferenceWorkers,
// biome-ignore lint/style/useNamingConvention: backend schema
max_conversation_restarts: settings.maxConversationRestarts,
// biome-ignore lint/style/useNamingConvention: backend schema
max_conversation_correction_steps: settings.maxConversationCorrectionSteps,
// biome-ignore lint/style/useNamingConvention: backend schema
disable_early_shutdown: settings.disableEarlyShutdown,
// biome-ignore lint/style/useNamingConvention: backend schema
shutdown_error_rate: settings.shutdownErrorRate,
// biome-ignore lint/style/useNamingConvention: backend schema
shutdown_error_window: settings.shutdownErrorWindow,
};
}
function applyGlobalParallelismOverride(
payload: RecipePayload,
llmParallelRequests: number | null,
): RecipePayload {
if (typeof llmParallelRequests !== "number") {
return payload;
}
const modelConfigs = payload.recipe.model_configs.map((modelConfig) => {
const nextModelConfig = { ...modelConfig };
const inferenceRaw = modelConfig.inference_parameters;
const inference =
inferenceRaw &&
typeof inferenceRaw === "object" &&
!Array.isArray(inferenceRaw)
? { ...(inferenceRaw as Record<string, unknown>) }
: {};
// biome-ignore lint/style/useNamingConvention: backend schema
inference.max_parallel_requests = llmParallelRequests;
// biome-ignore lint/style/useNamingConvention: backend schema
nextModelConfig.inference_parameters = inference;
return nextModelConfig;
});
return {
...payload,
recipe: {
...payload.recipe,
// biome-ignore lint/style/useNamingConvention: backend schema
model_configs: modelConfigs,
},
};
}
export function buildExecutionPayload(input: {
payload: RecipePayload;
kind: RecipeExecutionKind;
rows: number;
settings: RecipeRunSettings;
}): RecipePayload {
const normalizedSettings = normalizeRunSettings(input.settings);
const payloadWithParallelism = applyGlobalParallelismOverride(
input.payload,
normalizedSettings.llmParallelRequests,
);
return {
...payloadWithParallelism,
run: {
...payloadWithParallelism.run,
rows: input.rows,
// biome-ignore lint/style/useNamingConvention: backend schema
execution_type: input.kind,
// biome-ignore lint/style/useNamingConvention: backend schema
run_config: buildRunConfigPayload(normalizedSettings),
},
};
}

View file

@ -8,7 +8,10 @@ import {
validateRecipe,
} from "../api";
import { saveRecipeExecution } from "../data/executions-db";
import type { RecipeExecutionRecord } from "../execution-types";
import type {
RecipeExecutionKind,
RecipeExecutionRecord,
} from "../execution-types";
import {
DATASET_PAGE_SIZE,
executionLabel,
@ -21,8 +24,15 @@ import {
loadSortedRecipeExecutions,
} from "../executions/hydration";
import { createBaseExecutionRecord } from "../executions/runtime";
import {
buildExecutionPayload,
sanitizeExecutionRows,
} from "../executions/run-settings";
import { trackRecipeExecution } from "../executions/tracker";
import { useRecipeExecutionsStore } from "../stores/recipe-executions";
import {
type RecipeRunSettings,
useRecipeExecutionsStore,
} from "../stores/recipe-executions";
import type { RecipePayload, RecipePayloadResult } from "../utils/payload/types";
type UseRecipeExecutionsParams = {
@ -34,17 +44,23 @@ type UseRecipeExecutionsParams = {
};
type UseRecipeExecutionsResult = {
previewDialogOpen: boolean;
setPreviewDialogOpen: (open: boolean) => void;
runDialogOpen: boolean;
runDialogKind: RecipeExecutionKind;
setRunDialogOpen: (open: boolean) => void;
previewRows: number;
fullRows: number;
setPreviewRows: (rows: number) => void;
previewErrors: string[];
setFullRows: (rows: number) => void;
runErrors: string[];
runSettings: RecipeRunSettings;
setRunSettings: (patch: Partial<RecipeRunSettings>) => void;
previewLoading: boolean;
fullLoading: boolean;
executions: RecipeExecutionRecord[];
selectedExecutionId: string | null;
setSelectedExecutionId: (id: string) => void;
openPreviewDialog: () => void;
openRunDialog: (kind: RecipeExecutionKind) => void;
runFromDialog: () => Promise<boolean>;
runPreview: () => Promise<boolean>;
runFull: () => Promise<boolean>;
cancelExecution: (id: string) => Promise<void>;
@ -59,16 +75,22 @@ export function useRecipeExecutions({
onPreviewSuccess,
}: UseRecipeExecutionsParams): UseRecipeExecutionsResult {
const {
previewDialogOpen,
runDialogOpen,
runDialogKind,
previewRows,
previewErrors,
fullRows,
runErrors,
runSettings,
previewLoading,
fullLoading,
executions,
selectedExecutionId,
setPreviewDialogOpen,
setRunDialogOpen,
setRunDialogKind,
setPreviewRows,
setPreviewErrors,
setFullRows,
setRunErrors,
setRunSettings,
setPreviewLoading,
setFullLoading,
setExecutions,
@ -77,16 +99,22 @@ export function useRecipeExecutions({
resetForRecipe,
} = useRecipeExecutionsStore(
useShallow((state) => ({
previewDialogOpen: state.previewDialogOpen,
runDialogOpen: state.runDialogOpen,
runDialogKind: state.runDialogKind,
previewRows: state.previewRows,
previewErrors: state.previewErrors,
fullRows: state.fullRows,
runErrors: state.runErrors,
runSettings: state.runSettings,
previewLoading: state.previewLoading,
fullLoading: state.fullLoading,
executions: state.executions,
selectedExecutionId: state.selectedExecutionId,
setPreviewDialogOpen: state.setPreviewDialogOpen,
setRunDialogOpen: state.setRunDialogOpen,
setRunDialogKind: state.setRunDialogKind,
setPreviewRows: state.setPreviewRows,
setPreviewErrors: state.setPreviewErrors,
setFullRows: state.setFullRows,
setRunErrors: state.setRunErrors,
setRunSettings: state.setRunSettings,
setPreviewLoading: state.setPreviewLoading,
setFullLoading: state.setFullLoading,
setExecutions: state.setExecutions,
@ -134,7 +162,7 @@ export function useRecipeExecutions({
initialExecution: resumable,
notify: false,
onUpsert: upsertAndPersist,
onSetPreviewErrors: setPreviewErrors,
onSetPreviewErrors: setRunErrors,
onPreviewSuccess,
});
} catch (error) {
@ -152,7 +180,7 @@ export function useRecipeExecutions({
recipeId,
resetForRecipe,
setExecutions,
setPreviewErrors,
setRunErrors,
upsertAndPersist,
]);
@ -162,29 +190,26 @@ export function useRecipeExecutions({
}
return null;
}, [payloadResult.errors.length, payloadResult.payload]);
const readExecutablePayload = useCallback((): RecipePayload | null => {
const payload = readPayload();
if (payload) {
return payload;
}
setPreviewErrors(payloadResult.errors);
setRunErrors(payloadResult.errors);
toastError("Invalid recipe payload", payloadErrorMessage);
return null;
}, [payloadErrorMessage, payloadResult.errors, readPayload, setPreviewErrors]);
const openPreviewDialog = useCallback((): void => {
setPreviewErrors([]);
setPreviewDialogOpen(true);
}, [setPreviewDialogOpen, setPreviewErrors]);
}, [payloadErrorMessage, payloadResult.errors, readPayload, setRunErrors]);
const runExecution = useCallback(
async (input: {
kind: "preview" | "full";
kind: RecipeExecutionKind;
payload: RecipePayload;
rows: number;
settings: RecipeRunSettings;
}): Promise<boolean> => {
const { kind, payload, rows } = input;
const { kind, payload, rows, settings } = input;
const setLoading = kind === "preview" ? setPreviewLoading : setFullLoading;
const label = executionLabel(kind);
@ -198,20 +223,15 @@ export function useRecipeExecutions({
upsertAndPersist(baseExecution);
onExecutionStart?.();
if (kind === "preview") {
setPreviewDialogOpen(false);
}
setRunDialogOpen(false);
try {
const jobPayload = {
...payload,
run: {
...payload.run,
rows,
// biome-ignore lint/style/useNamingConvention: backend schema
execution_type: kind,
},
};
const jobPayload = buildExecutionPayload({
payload,
kind,
rows,
settings,
});
const createdJob = await createRecipeJob(jobPayload);
const executionWithJob = {
...baseExecution,
@ -227,7 +247,7 @@ export function useRecipeExecutions({
initialExecution: executionWithJob,
notify: true,
onUpsert: upsertAndPersist,
onSetPreviewErrors: setPreviewErrors,
onSetPreviewErrors: setRunErrors,
onPreviewSuccess,
});
} catch (error) {
@ -238,9 +258,7 @@ export function useRecipeExecutions({
error: message,
finishedAt: Date.now(),
});
if (kind === "preview") {
setPreviewErrors([message]);
}
setRunErrors([message]);
toastError(`${label} failed`, message);
return false;
} finally {
@ -253,68 +271,91 @@ export function useRecipeExecutions({
onPreviewSuccess,
recipeId,
setFullLoading,
setPreviewDialogOpen,
setPreviewErrors,
setPreviewLoading,
setRunDialogOpen,
setRunErrors,
upsertAndPersist,
],
);
const runPreview = useCallback(async (): Promise<boolean> => {
const payload = readExecutablePayload();
if (!payload) {
return false;
}
const previewPayload = {
...payload,
run: {
...payload.run,
rows: previewRows,
},
};
try {
const validation = await validateRecipe(previewPayload);
if (!validation.valid) {
const errors = validation.errors.map((item) => item.message);
const fallback = validation.raw_detail ?? "Validation failed.";
const nextErrors = errors.length > 0 ? errors : [fallback];
setPreviewErrors(nextErrors);
toastError("Validation failed", nextErrors[0]);
const runWithValidation = useCallback(
async (kind: RecipeExecutionKind, rows: number): Promise<boolean> => {
const payload = readExecutablePayload();
if (!payload) {
return false;
}
} catch (error) {
const message = toErrorMessage(error, "Validation failed.");
setPreviewErrors([message]);
toastError("Validation failed", message);
return false;
}
return runExecution({
kind: "preview",
payload,
rows: previewRows,
});
}, [previewRows, readExecutablePayload, runExecution, setPreviewErrors]);
const normalizedRows = sanitizeExecutionRows(rows, kind);
const executionPayload = buildExecutionPayload({
payload,
kind,
rows: normalizedRows,
settings: runSettings,
});
try {
const validation = await validateRecipe(executionPayload);
if (!validation.valid) {
const errors = validation.errors.map((item) => item.message);
const fallback = validation.raw_detail ?? "Validation failed.";
const nextErrors = errors.length > 0 ? errors : [fallback];
setRunErrors(nextErrors);
toastError("Validation failed", nextErrors[0]);
return false;
}
} catch (error) {
const message = toErrorMessage(error, "Validation failed.");
setRunErrors([message]);
toastError("Validation failed", message);
return false;
}
return runExecution({
kind,
payload,
rows: normalizedRows,
settings: runSettings,
});
},
[readExecutablePayload, runExecution, runSettings, setRunErrors],
);
const runPreview = useCallback(async (): Promise<boolean> => {
return runWithValidation("preview", previewRows);
}, [previewRows, runWithValidation]);
const runFull = useCallback(async (): Promise<boolean> => {
const payload = readExecutablePayload();
if (!payload) {
return false;
return runWithValidation("full", fullRows);
}, [fullRows, runWithValidation]);
const runFromDialog = useCallback(async (): Promise<boolean> => {
if (runDialogKind === "preview") {
return runPreview();
}
return runFull();
}, [runDialogKind, runFull, runPreview]);
const requestedRows = Number(payload.run?.rows);
const rows =
Number.isFinite(requestedRows) && requestedRows > 0
? Math.floor(requestedRows)
: 1000;
return runExecution({
kind: "full",
payload,
rows,
});
}, [readExecutablePayload, runExecution]);
const openRunDialog = useCallback(
(kind: RecipeExecutionKind): void => {
setRunErrors([]);
setRunDialogKind(kind);
if (kind === "full") {
const payload = readPayload();
const payloadRows = Number(payload?.run?.rows);
if (Number.isFinite(payloadRows) && payloadRows > 0) {
setFullRows(Math.floor(payloadRows));
}
}
setRunDialogOpen(true);
},
[
readPayload,
setFullRows,
setRunDialogKind,
setRunDialogOpen,
setRunErrors,
],
);
const cancelExecution = useCallback(
async (id: string): Promise<void> => {
@ -375,17 +416,23 @@ export function useRecipeExecutions({
);
return {
previewDialogOpen,
setPreviewDialogOpen,
runDialogOpen,
runDialogKind,
setRunDialogOpen,
previewRows,
fullRows,
setPreviewRows,
previewErrors,
setFullRows,
runErrors,
runSettings,
setRunSettings,
previewLoading,
fullLoading,
executions,
selectedExecutionId,
setSelectedExecutionId,
openPreviewDialog,
openRunDialog,
runFromDialog,
runPreview,
runFull,
cancelExecution,

View file

@ -1,6 +1,10 @@
import { useRecipeExecutions } from "./use-recipe-executions";
import { useRecipePersistence } from "./use-recipe-persistence";
import type { RecipeExecutionRecord } from "../execution-types";
import type {
RecipeExecutionKind,
RecipeExecutionRecord,
} from "../execution-types";
import type { RecipeRunSettings } from "../stores/recipe-executions";
import type { RecipeSnapshot } from "../utils/import";
import type { RecipePayload, RecipePayloadResult } from "../utils/payload/types";
@ -38,11 +42,16 @@ type UseRecipeStudioActionsResult = {
copied: boolean;
importOpen: boolean;
setImportOpen: (open: boolean) => void;
previewDialogOpen: boolean;
setPreviewDialogOpen: (open: boolean) => void;
runDialogOpen: boolean;
runDialogKind: RecipeExecutionKind;
setRunDialogOpen: (open: boolean) => void;
previewRows: number;
fullRows: number;
setPreviewRows: (rows: number) => void;
previewErrors: string[];
setFullRows: (rows: number) => void;
runErrors: string[];
runSettings: RecipeRunSettings;
setRunSettings: (patch: Partial<RecipeRunSettings>) => void;
previewLoading: boolean;
fullLoading: boolean;
currentSignature: string;
@ -50,7 +59,8 @@ type UseRecipeStudioActionsResult = {
selectedExecutionId: string | null;
setSelectedExecutionId: (id: string) => void;
persistRecipe: () => Promise<void>;
openPreviewDialog: () => void;
openRunDialog: (kind: RecipeExecutionKind) => void;
runFromDialog: () => Promise<boolean>;
runPreview: () => Promise<boolean>;
runFull: () => Promise<boolean>;
cancelExecution: (id: string) => Promise<void>;
@ -101,11 +111,16 @@ export function useRecipeStudioActions({
copied: persistence.copied,
importOpen: persistence.importOpen,
setImportOpen: persistence.setImportOpen,
previewDialogOpen: executions.previewDialogOpen,
setPreviewDialogOpen: executions.setPreviewDialogOpen,
runDialogOpen: executions.runDialogOpen,
runDialogKind: executions.runDialogKind,
setRunDialogOpen: executions.setRunDialogOpen,
previewRows: executions.previewRows,
fullRows: executions.fullRows,
setPreviewRows: executions.setPreviewRows,
previewErrors: executions.previewErrors,
setFullRows: executions.setFullRows,
runErrors: executions.runErrors,
runSettings: executions.runSettings,
setRunSettings: executions.setRunSettings,
previewLoading: executions.previewLoading,
fullLoading: executions.fullLoading,
currentSignature: persistence.currentSignature,
@ -113,7 +128,8 @@ export function useRecipeStudioActions({
selectedExecutionId: executions.selectedExecutionId,
setSelectedExecutionId: executions.setSelectedExecutionId,
persistRecipe: persistence.persistRecipe,
openPreviewDialog: executions.openPreviewDialog,
openRunDialog: executions.openRunDialog,
runFromDialog: executions.runFromDialog,
runPreview: executions.runPreview,
runFull: executions.runFull,
cancelExecution: executions.cancelExecution,

View file

@ -33,7 +33,7 @@ import { RecipeGraphSemanticEdge } from "./components/recipe-graph-semantic-edge
import { DataEdge } from "./components/rf-ui/data-edge";
import { ConfigDialog } from "./dialogs/config-dialog";
import { ImportDialog } from "./dialogs/import-dialog";
import { PreviewDialog } from "./dialogs/preview-dialog";
import { RunDialog } from "./dialogs/preview-dialog";
import { ProcessorsDialog } from "./dialogs/processors-dialog";
import { useRecipeStudioActions } from "./hooks/use-recipe-studio-actions";
import { useRecipeStudioStore } from "./stores/recipe-studio";
@ -52,7 +52,6 @@ import {
} from "./utils/reactflow-changes";
import {
buildDialogOptions,
buildPreviewSummary,
} from "./utils/recipe-studio-view";
import type { RecipeStudioView } from "./execution-types";
@ -251,10 +250,6 @@ export function RecipeStudioPage({
() => buildDialogOptions(configList),
[configList],
);
const previewSummary = useMemo(
() => buildPreviewSummary(configList),
[configList],
);
const handleToggleDirection = useCallback(() => {
setLayoutDirection(layoutDirection === "LR" ? "TB" : "LR");
@ -286,11 +281,16 @@ export function RecipeStudioPage({
copied,
importOpen,
setImportOpen,
previewDialogOpen,
setPreviewDialogOpen,
runDialogOpen,
runDialogKind,
setRunDialogOpen,
previewRows,
fullRows,
setPreviewRows,
previewErrors,
setFullRows,
runErrors,
runSettings,
setRunSettings,
previewLoading,
fullLoading,
currentSignature,
@ -298,9 +298,8 @@ export function RecipeStudioPage({
selectedExecutionId,
setSelectedExecutionId,
persistRecipe,
openPreviewDialog,
runPreview,
runFull,
openRunDialog,
runFromDialog,
cancelExecution,
loadExecutionDatasetPage,
copyRecipe,
@ -334,6 +333,9 @@ export function RecipeStudioPage({
setSheetView("root");
setBlockSheetOpen(true);
}, [setSheetView]);
const runDialogRows = runDialogKind === "preview" ? previewRows : fullRows;
const runDialogLoading =
runDialogKind === "preview" ? previewLoading : fullLoading;
return (
<div className="min-h-screen bg-background">
@ -352,10 +354,8 @@ export function RecipeStudioPage({
workflowName={workflowName}
onWorkflowNameChange={setWorkflowName}
onViewChange={setActiveView}
onPreview={openPreviewDialog}
onRunFull={() => {
void runFull();
}}
onPreview={() => openRunDialog("preview")}
onRunFull={() => openRunDialog("full")}
onSaveRecipe={() => {
void persistRecipe();
}}
@ -483,16 +483,24 @@ export function RecipeStudioPage({
onProcessorsChange={setProcessors}
container={sheetContainer}
/>
<PreviewDialog
open={previewDialogOpen}
onOpenChange={setPreviewDialogOpen}
rows={previewRows}
onRowsChange={setPreviewRows}
loading={previewLoading}
errors={previewErrors}
summary={previewSummary}
onPreview={() => {
void runPreview();
<RunDialog
open={runDialogOpen}
onOpenChange={setRunDialogOpen}
kind={runDialogKind}
rows={runDialogRows}
onRowsChange={(rows) => {
if (runDialogKind === "preview") {
setPreviewRows(rows);
return;
}
setFullRows(rows);
}}
settings={runSettings}
onSettingsChange={setRunSettings}
loading={runDialogLoading}
errors={runErrors}
onRun={() => {
void runFromDialog();
}}
container={sheetContainer}
/>

View file

@ -1,18 +1,47 @@
import { create } from "zustand";
import type { RecipeExecutionKind } from "../execution-types";
import type { RecipeExecutionRecord } from "../execution-types";
import { sortExecutions, withExecutionDefaults } from "../executions/execution-helpers";
export type RecipeRunSettings = {
bufferSize: number;
llmParallelRequests: number | null;
nonInferenceWorkers: number;
maxConversationRestarts: number;
maxConversationCorrectionSteps: number;
disableEarlyShutdown: boolean;
shutdownErrorRate: number;
shutdownErrorWindow: number;
};
const DEFAULT_RUN_SETTINGS: RecipeRunSettings = {
bufferSize: 1000,
llmParallelRequests: null,
nonInferenceWorkers: 4,
maxConversationRestarts: 5,
maxConversationCorrectionSteps: 0,
disableEarlyShutdown: false,
shutdownErrorRate: 0.5,
shutdownErrorWindow: 10,
};
type RecipeExecutionsState = {
previewDialogOpen: boolean;
runDialogOpen: boolean;
runDialogKind: RecipeExecutionKind;
previewRows: number;
previewErrors: string[];
fullRows: number;
runErrors: string[];
runSettings: RecipeRunSettings;
previewLoading: boolean;
fullLoading: boolean;
executions: RecipeExecutionRecord[];
selectedExecutionId: string | null;
setPreviewDialogOpen: (open: boolean) => void;
setRunDialogOpen: (open: boolean) => void;
setRunDialogKind: (kind: RecipeExecutionKind) => void;
setPreviewRows: (rows: number) => void;
setPreviewErrors: (errors: string[]) => void;
setFullRows: (rows: number) => void;
setRunErrors: (errors: string[]) => void;
setRunSettings: (patch: Partial<RecipeRunSettings>) => void;
setPreviewLoading: (loading: boolean) => void;
setFullLoading: (loading: boolean) => void;
setExecutions: (records: RecipeExecutionRecord[]) => void;
@ -22,18 +51,24 @@ type RecipeExecutionsState = {
};
const INITIAL_STATE = {
previewDialogOpen: false,
runDialogOpen: false,
runDialogKind: "preview",
previewRows: 5,
previewErrors: [],
fullRows: 1000,
runErrors: [],
runSettings: DEFAULT_RUN_SETTINGS,
previewLoading: false,
fullLoading: false,
executions: [],
selectedExecutionId: null,
} satisfies Pick<
RecipeExecutionsState,
| "previewDialogOpen"
| "runDialogOpen"
| "runDialogKind"
| "previewRows"
| "previewErrors"
| "fullRows"
| "runErrors"
| "runSettings"
| "previewLoading"
| "fullLoading"
| "executions"
@ -42,10 +77,20 @@ const INITIAL_STATE = {
export const useRecipeExecutionsStore = create<RecipeExecutionsState>((set) => ({
...INITIAL_STATE,
setPreviewDialogOpen: (open) => set({ previewDialogOpen: open }),
setRunDialogOpen: (open) => set({ runDialogOpen: open }),
setRunDialogKind: (kind) => set({ runDialogKind: kind }),
setPreviewRows: (rows) =>
set({ previewRows: Number.isFinite(rows) && rows > 0 ? Math.floor(rows) : 1 }),
setPreviewErrors: (errors) => set({ previewErrors: errors }),
setFullRows: (rows) =>
set({ fullRows: Number.isFinite(rows) && rows > 0 ? Math.floor(rows) : 1 }),
setRunErrors: (errors) => set({ runErrors: errors }),
setRunSettings: (patch) =>
set((state) => ({
runSettings: {
...state.runSettings,
...patch,
},
})),
setPreviewLoading: (loading) => set({ previewLoading: loading }),
setFullLoading: (loading) => set({ fullLoading: loading }),
setExecutions: (records) =>

View file

@ -0,0 +1,36 @@
import { Position } from "@xyflow/react";
import type { LayoutDirection } from "../types";
export const NODE_HANDLE_CLASS =
"pointer-events-auto !size-2.5 !border-border/80 !bg-muted shadow-sm hover:!border-primary/70 hover:!bg-primary/20";
export const AUX_HANDLE_CLASS =
"!size-2 !border-border/80 !bg-muted/80 shadow-sm";
export type NodeHandleLayout = {
isTopBottom: boolean;
dataInPosition: Position;
dataOutPosition: Position;
semanticInPosition: Position;
semanticOutPosition: Position;
};
export function getNodeHandleLayout(
direction: LayoutDirection,
): NodeHandleLayout {
const isTopBottom = direction === "TB";
return {
isTopBottom,
dataInPosition: isTopBottom ? Position.Top : Position.Left,
dataOutPosition: isTopBottom ? Position.Bottom : Position.Right,
semanticInPosition: isTopBottom ? Position.Left : Position.Top,
semanticOutPosition: isTopBottom ? Position.Right : Position.Bottom,
};
}
export function getAuxSourceHandlePosition(
direction: LayoutDirection,
): Position {
return direction === "TB" ? Position.Bottom : Position.Right;
}

View file

@ -18,6 +18,14 @@ export type RecipePayload = {
preview: boolean;
// biome-ignore lint/style/useNamingConvention: api schema
output_formats: string[];
// biome-ignore lint/style/useNamingConvention: backend schema
execution_type?: "preview" | "full";
// biome-ignore lint/style/useNamingConvention: backend schema
run_config?: Record<string, unknown>;
// biome-ignore lint/style/useNamingConvention: backend schema
dataset_name?: string;
// biome-ignore lint/style/useNamingConvention: backend schema
artifact_path?: string;
};
ui: {
nodes: { id: string; x: number; y: number }[];

View file

@ -1,14 +1,5 @@
import type { NodeConfig, SamplerConfig } from "../types";
export type PreviewSummary = {
totalColumns: number;
llmColumns: number;
samplerColumns: number;
expressionColumns: number;
toolConfigs: number;
mcpProviders: number;
};
export type DialogOptions = {
categoryOptions: SamplerConfig[];
modelConfigAliases: string[];
@ -48,53 +39,3 @@ export function buildDialogOptions(configList: NodeConfig[]): DialogOptions {
datetimeOptions,
};
}
export function buildPreviewSummary(configList: NodeConfig[]): PreviewSummary {
const toolConfigAliases = new Set<string>();
const mcpProviderNames = new Set<string>();
let totalColumns = 0;
let llmColumns = 0;
let samplerColumns = 0;
let expressionColumns = 0;
for (const config of configList) {
if (config.kind === "sampler") {
totalColumns += 1;
samplerColumns += 1;
continue;
}
if (config.kind === "expression") {
totalColumns += 1;
expressionColumns += 1;
continue;
}
if (config.kind !== "llm") {
continue;
}
totalColumns += 1;
llmColumns += 1;
for (const toolConfig of config.tool_configs ?? []) {
const toolAlias = toolConfig.tool_alias.trim();
if (toolAlias) {
toolConfigAliases.add(toolAlias);
}
}
for (const provider of config.mcp_providers ?? []) {
const providerName = provider.name.trim();
if (providerName) {
mcpProviderNames.add(providerName);
}
}
}
return {
totalColumns,
llmColumns,
samplerColumns,
expressionColumns,
toolConfigs: toolConfigAliases.size,
mcpProviders: mcpProviderNames.size,
};
}