feat: add recipe execution stores, hooks, and logic for managing preview and full executions

This commit is contained in:
Shine1i 2026-02-20 14:38:05 +01:00
commit d1fbf272b0
8 changed files with 1125 additions and 831 deletions

View file

@ -33,16 +33,13 @@ import type {
RecipeExecutionRecord,
RecipeExecutionStatus,
} from "../../execution-types";
import { isExecutionInProgress } from "../../executions/execution-helpers";
type ExecutionsViewProps = {
executions: RecipeExecutionRecord[];
selectedExecutionId: string | null;
currentSignature: string;
previewLoading: boolean;
fullLoading: boolean;
onSelectExecution: (id: string) => void;
onRunPreview: () => void;
onRunFull: () => void;
onCancelExecution: (id: string) => void;
onLoadDatasetPage: (id: string, page: number) => void;
};
@ -126,15 +123,6 @@ function parseAnalysisColumns(analysis: RecipeExecutionAnalysis | null): Analysi
.filter((item): item is AnalysisColumnStat => item !== null);
}
function isInProgress(status: RecipeExecutionStatus): boolean {
return (
status === "running" ||
status === "active" ||
status === "pending" ||
status === "cancelling"
);
}
function statusTone(status: RecipeExecutionStatus): string {
if (status === "completed") {
return "bg-emerald-100 text-emerald-700";
@ -142,7 +130,7 @@ function statusTone(status: RecipeExecutionStatus): string {
if (status === "error" || status === "cancelled") {
return "bg-red-100 text-red-700";
}
if (isInProgress(status)) {
if (isExecutionInProgress(status)) {
return "bg-amber-100 text-amber-700";
}
return "bg-muted text-muted-foreground";
@ -155,7 +143,7 @@ function statusRightBorder(status: RecipeExecutionStatus): string {
if (status === "error" || status === "cancelled") {
return "border-r-red-500";
}
if (isInProgress(status)) {
if (isExecutionInProgress(status)) {
return "border-r-amber-500";
}
return "border-r-border";
@ -221,11 +209,7 @@ export function ExecutionsView({
executions,
selectedExecutionId,
currentSignature,
previewLoading,
fullLoading,
onSelectExecution,
onRunPreview,
onRunFull,
onCancelExecution,
onLoadDatasetPage,
}: ExecutionsViewProps): ReactElement {
@ -349,7 +333,7 @@ export function ExecutionsView({
}, [selectedExecution?.analysis?.side_effect_column_names]);
const canCancel = Boolean(
selectedExecution?.jobId && isInProgress(selectedExecution.status),
selectedExecution?.jobId && isExecutionInProgress(selectedExecution.status),
);
const datasetPage = selectedExecution?.datasetPage ?? 1;
const datasetPageSize = selectedExecution?.datasetPageSize ?? 20;
@ -429,7 +413,7 @@ export function ExecutionsView({
const showSummaryCards = selectedExecution?.status === "completed";
const showProgressPanel =
selectedExecution?.status === "completed" ||
(selectedExecution ? isInProgress(selectedExecution.status) : false);
(selectedExecution ? isExecutionInProgress(selectedExecution.status) : false);
const progressComplete = selectedExecution?.status === "completed";
const progressPercent = selectedExecution?.progress?.percent ?? (progressComplete ? 100 : 0);
const terminalLines = selectedExecution?.log_lines ?? [];
@ -455,25 +439,6 @@ export function ExecutionsView({
<p className="text-xs font-semibold uppercase text-muted-foreground">
Executions
</p>
<div className="flex items-center gap-1">
<Button
type="button"
size="sm"
variant="outline"
onClick={onRunPreview}
disabled={previewLoading}
>
{previewLoading ? "Running..." : "Preview"}
</Button>
<Button
type="button"
size="sm"
onClick={onRunFull}
disabled={fullLoading}
>
{fullLoading ? "Starting..." : "Full run"}
</Button>
</div>
</div>
<div className="h-[calc(100%-45px)] overflow-auto p-2">
{executions.length === 0 ? (
@ -632,7 +597,7 @@ export function ExecutionsView({
)}
{(selectedExecution.status === "completed" ||
isInProgress(selectedExecution.status)) && (
isExecutionInProgress(selectedExecution.status)) && (
<Tabs value={detailTab} onValueChange={setDetailTab}>
<div className="flex items-center justify-between gap-2">
<TabsList>
@ -815,7 +780,7 @@ export function ExecutionsView({
>
{terminalLines.length === 0 ? (
<p className="text-zinc-400">
{isInProgress(selectedExecution.status)
{isExecutionInProgress(selectedExecution.status)
? "Waiting for logs..."
: "No logs captured."}
</p>
@ -911,7 +876,7 @@ export function ExecutionsView({
size="sm"
variant="outline"
disabled={
isInProgress(selectedExecution.status) || datasetPage <= 1
isExecutionInProgress(selectedExecution.status) || datasetPage <= 1
}
onClick={() =>
onLoadDatasetPage(selectedExecution.id, datasetPage - 1)}
@ -923,7 +888,7 @@ export function ExecutionsView({
size="sm"
variant="outline"
disabled={
isInProgress(selectedExecution.status) ||
isExecutionInProgress(selectedExecution.status) ||
datasetPage >= totalPages
}
onClick={() =>

View file

@ -16,6 +16,7 @@ type StatusTone = "success" | "error";
type RecipeStudioHeaderProps = {
activeView: RecipeStudioView;
previewLoading: boolean;
fullLoading: boolean;
saveLoading: boolean;
saveTone: StatusTone;
savedAtLabel: string;
@ -23,6 +24,7 @@ type RecipeStudioHeaderProps = {
onWorkflowNameChange: (value: string) => void;
onViewChange: (view: RecipeStudioView) => void;
onPreview: () => void;
onRunFull: () => void;
onSaveRecipe: () => void;
};
@ -34,6 +36,7 @@ const STATUS_MESSAGE_CLASS: Record<StatusTone, string> = {
export function RecipeStudioHeader({
activeView,
previewLoading,
fullLoading,
saveLoading,
saveTone,
savedAtLabel,
@ -41,6 +44,7 @@ export function RecipeStudioHeader({
onWorkflowNameChange,
onViewChange,
onPreview,
onRunFull,
onSaveRecipe,
}: RecipeStudioHeaderProps): ReactElement {
const [editingWorkflowName, setEditingWorkflowName] = useState(false);
@ -116,6 +120,10 @@ export function RecipeStudioHeader({
<HugeiconsIcon icon={TestTubeIcon} className="size-3.5" />
{previewLoading ? "Previewing..." : "Preview"}
</Button>
<Button type="button" size="sm" onClick={onRunFull} disabled={fullLoading}>
<HugeiconsIcon icon={TestTubeIcon} className="size-3.5" />
{fullLoading ? "Starting..." : "Full run"}
</Button>
<Button
type="button"
size="sm"

View file

@ -76,12 +76,21 @@ export function mapJobStatus(status: string): RecipeExecutionStatus {
return "running";
}
export function isExecutionInProgress(status: RecipeExecutionStatus): boolean {
return (
status === "running" ||
status === "active" ||
status === "pending" ||
status === "cancelling"
);
}
export function executionLabel(kind: "preview" | "full"): string {
return kind === "preview" ? "Preview" : "Full run";
}
function executionSortWeight(status: RecipeExecutionStatus): number {
if (status === "running" || status === "active" || status === "pending" || status === "cancelling") {
if (isExecutionInProgress(status)) {
return 0;
}
if (status === "error" || status === "cancelled") {

View file

@ -0,0 +1,781 @@
import { useCallback, useEffect } from "react";
import { useShallow } from "zustand/react/shallow";
import { toastError, toastSuccess } from "@/shared/toast";
import {
cancelRecipeJob,
createRecipeJob,
getRecipeJobAnalysis,
getRecipeJobDataset,
getRecipeJobStatus,
streamRecipeJobEvents,
validateRecipe,
type JobEvent,
type JobStatusResponse,
} from "../api";
import { listRecipeExecutions, saveRecipeExecution } from "../data/executions-db";
import type {
RecipeExecutionRecord,
RecipeExecutionStatus,
} from "../execution-types";
import {
DATASET_PAGE_SIZE,
delay,
executionLabel,
isExecutionInProgress,
mapJobStatus,
normalizeAnalysis,
normalizeDatasetRows,
normalizeObject,
sortExecutions,
toErrorMessage,
withExecutionDefaults,
} from "../executions/execution-helpers";
import { useRecipeExecutionsStore } from "../stores/recipe-executions";
import type { RecipePayload, RecipePayloadResult } from "../utils/payload/types";
type UseRecipeExecutionsParams = {
recipeId: string;
currentSignature: string;
payloadResult: RecipePayloadResult;
onExecutionStart?: () => void;
onPreviewSuccess?: () => void;
};
type UseRecipeExecutionsResult = {
previewDialogOpen: boolean;
setPreviewDialogOpen: (open: boolean) => void;
previewRows: number;
setPreviewRows: (rows: number) => void;
previewErrors: string[];
previewLoading: boolean;
fullLoading: boolean;
executions: RecipeExecutionRecord[];
selectedExecutionId: string | null;
setSelectedExecutionId: (id: string) => void;
openPreviewDialog: () => void;
runPreview: () => Promise<boolean>;
runFull: () => Promise<boolean>;
cancelExecution: (id: string) => Promise<void>;
loadExecutionDatasetPage: (id: string, page: number) => Promise<void>;
};
const MAX_LOG_LINES = 1500;
function formatEventTime(ts: unknown): string {
if (typeof ts !== "number" || !Number.isFinite(ts)) {
return new Date().toLocaleTimeString();
}
const ms = ts > 10_000_000_000 ? ts : ts * 1000;
return new Date(ms).toLocaleTimeString();
}
function appendLogLine(lines: string[], nextLine: string): string[] {
const next = [...lines, nextLine];
if (next.length <= MAX_LOG_LINES) {
return next;
}
return next.slice(next.length - MAX_LOG_LINES);
}
function toLogLine(event: JobEvent): string | null {
const eventType =
typeof event.payload.type === "string" ? event.payload.type : event.event;
const ts = formatEventTime(event.payload.ts);
if (eventType === "log") {
const message =
typeof event.payload.message === "string" ? event.payload.message.trim() : "";
if (!message) {
return null;
}
const level =
typeof event.payload.level === "string" && event.payload.level.length > 0
? event.payload.level.toUpperCase()
: "INFO";
return `[${ts}] [${level}] ${message}`;
}
if (eventType === "job.started") {
return `[${ts}] [INFO] Job started`;
}
if (eventType === "job.completed") {
return `[${ts}] [INFO] Job completed`;
}
if (eventType === "job.cancelling") {
return `[${ts}] [WARN] Cancellation requested`;
}
if (eventType === "job.cancelled") {
return `[${ts}] [WARN] Job cancelled`;
}
if (eventType === "job.error") {
const error =
typeof event.payload.error === "string" && event.payload.error.length > 0
? event.payload.error
: "Job failed";
return `[${ts}] [ERROR] ${error}`;
}
return null;
}
function applyStatusSnapshot(
execution: RecipeExecutionRecord,
status: JobStatusResponse,
): RecipeExecutionRecord {
const mappedStatus = mapJobStatus(status.status);
return {
...execution,
status: mappedStatus,
rows: status.rows ?? execution.rows,
stage: status.stage ?? execution.stage,
current_column: status.current_column ?? null,
progress: (normalizeObject(status.progress) as RecipeExecutionRecord["progress"]) ?? null,
column_progress:
(normalizeObject(status.column_progress) as RecipeExecutionRecord["column_progress"]) ??
null,
model_usage: normalizeObject(status.model_usage),
artifact_path: status.artifact_path ?? execution.artifact_path,
error: status.error ?? null,
finishedAt:
mappedStatus === "completed" ||
mappedStatus === "error" ||
mappedStatus === "cancelled"
? Date.now()
: null,
};
}
function createBaseExecution(input: {
recipeId: string;
kind: "preview" | "full";
rows: number;
currentSignature: string;
}): RecipeExecutionRecord {
const createdAt = Date.now();
return {
id: crypto.randomUUID(),
recipeId: input.recipeId,
jobId: null,
kind: input.kind,
status: "pending",
rows: input.rows,
createdAt,
finishedAt: null,
recipeSignature: input.currentSignature,
stage: "pending",
current_column: null,
progress: null,
column_progress: null,
model_usage: null,
lastEventId: null,
artifact_path: null,
log_lines: [],
dataset: [],
datasetTotal: 0,
datasetPage: 1,
datasetPageSize: DATASET_PAGE_SIZE,
analysis: null,
processor_artifacts: null,
error: null,
};
}
export function useRecipeExecutions({
recipeId,
currentSignature,
payloadResult,
onExecutionStart,
onPreviewSuccess,
}: UseRecipeExecutionsParams): UseRecipeExecutionsResult {
const {
previewDialogOpen,
previewRows,
previewErrors,
previewLoading,
fullLoading,
executions,
selectedExecutionId,
setPreviewDialogOpen,
setPreviewRows,
setPreviewErrors,
setPreviewLoading,
setFullLoading,
setExecutions,
upsertExecution,
selectExecution,
resetForRecipe,
} = useRecipeExecutionsStore(
useShallow((state) => ({
previewDialogOpen: state.previewDialogOpen,
previewRows: state.previewRows,
previewErrors: state.previewErrors,
previewLoading: state.previewLoading,
fullLoading: state.fullLoading,
executions: state.executions,
selectedExecutionId: state.selectedExecutionId,
setPreviewDialogOpen: state.setPreviewDialogOpen,
setPreviewRows: state.setPreviewRows,
setPreviewErrors: state.setPreviewErrors,
setPreviewLoading: state.setPreviewLoading,
setFullLoading: state.setFullLoading,
setExecutions: state.setExecutions,
upsertExecution: state.upsertExecution,
selectExecution: state.selectExecution,
resetForRecipe: state.resetForRecipe,
})),
);
const upsertAndPersist = useCallback(
(record: RecipeExecutionRecord): void => {
const normalized = withExecutionDefaults(record);
upsertExecution(normalized);
void saveRecipeExecution(normalized).catch((error) => {
console.error("Save recipe execution failed:", error);
});
},
[upsertExecution],
);
const trackExecution = useCallback(
async (input: {
label: string;
kind: "preview" | "full";
rows: number;
jobId: string;
initialExecution: RecipeExecutionRecord;
notify: boolean;
}): Promise<boolean> => {
const { label, kind, rows, jobId, notify } = input;
let done = false;
let lastStatus: RecipeExecutionStatus = input.initialExecution.status;
let completedEventPayload: Record<string, unknown> | null = null;
let latestExecution: RecipeExecutionRecord = input.initialExecution;
const eventsAbortController = new AbortController();
void streamRecipeJobEvents({
jobId,
signal: eventsAbortController.signal,
lastEventId: latestExecution.lastEventId,
onEvent: (event) => {
let changed = false;
if (typeof event.id === "number") {
latestExecution = {
...latestExecution,
lastEventId: event.id,
};
changed = true;
}
const logLine = toLogLine(event);
if (logLine) {
latestExecution = {
...latestExecution,
log_lines: appendLogLine(latestExecution.log_lines, logLine),
};
changed = true;
}
const eventType =
typeof event.payload.type === "string" ? event.payload.type : event.event;
if (eventType === "job.started") {
latestExecution = {
...latestExecution,
status: "active",
};
upsertAndPersist(latestExecution);
return;
}
if (eventType === "job.completed") {
lastStatus = "completed";
completedEventPayload = event.payload;
done = true;
latestExecution = {
...latestExecution,
status: "completed",
finishedAt: Date.now(),
artifact_path:
typeof event.payload.artifact_path === "string"
? event.payload.artifact_path
: latestExecution.artifact_path,
error: null,
};
upsertAndPersist(latestExecution);
return;
}
if (eventType === "job.error") {
lastStatus = "error";
done = true;
latestExecution = {
...latestExecution,
status: "error",
finishedAt: Date.now(),
error:
typeof event.payload.error === "string"
? event.payload.error
: latestExecution.error ?? `${label} failed.`,
};
upsertAndPersist(latestExecution);
return;
}
if (eventType === "job.cancelling") {
latestExecution = {
...latestExecution,
status: "cancelling",
};
upsertAndPersist(latestExecution);
return;
}
if (changed) {
upsertAndPersist(latestExecution);
}
},
}).catch(() => {
// polling is fallback source of truth
});
try {
while (!done) {
const status = await getRecipeJobStatus(jobId);
const mappedStatus = mapJobStatus(status.status);
lastStatus = mappedStatus;
latestExecution = applyStatusSnapshot(latestExecution, status);
upsertAndPersist(latestExecution);
done =
mappedStatus === "completed" ||
mappedStatus === "error" ||
mappedStatus === "cancelled";
if (!done) {
await delay(1200);
}
}
} catch (error) {
const message = toErrorMessage(error, `${label} failed.`);
latestExecution = {
...latestExecution,
status: "error",
error: message,
finishedAt: Date.now(),
};
upsertAndPersist(latestExecution);
if (notify) {
toastError(`${label} failed`, message);
}
return false;
} finally {
eventsAbortController.abort();
}
if (lastStatus === "completed") {
for (let attempt = 0; attempt < 3; attempt += 1) {
try {
const finalStatus = await getRecipeJobStatus(jobId);
latestExecution = applyStatusSnapshot(latestExecution, finalStatus);
} catch {
break;
}
if (attempt < 2) {
await delay(250);
}
}
const eventAnalysis = completedEventPayload
? completedEventPayload["analysis"]
: null;
const eventDataset = completedEventPayload
? completedEventPayload["dataset"]
: null;
const shouldFetchPreviewDataset =
kind === "preview" && !Array.isArray(eventDataset);
const shouldFetchAnalysis =
!completedEventPayload ||
typeof eventAnalysis !== "object" ||
eventAnalysis === null ||
kind === "full";
const [analysisResult, datasetResult] = await Promise.allSettled([
shouldFetchAnalysis
? getRecipeJobAnalysis(jobId)
: Promise.resolve(eventAnalysis),
shouldFetchPreviewDataset || kind === "full"
? getRecipeJobDataset(jobId, { limit: DATASET_PAGE_SIZE, offset: 0 })
: Promise.resolve({ dataset: eventDataset ?? [], total: rows }),
]);
const analysis =
analysisResult.status === "fulfilled"
? normalizeAnalysis(analysisResult.value)
: latestExecution.analysis;
const datasetResponse =
datasetResult.status === "fulfilled"
? datasetResult.value
: null;
const dataset = datasetResponse
? normalizeDatasetRows(datasetResponse.dataset)
: latestExecution.dataset;
const datasetTotal =
datasetResponse && typeof datasetResponse.total === "number"
? datasetResponse.total
: latestExecution.datasetTotal;
const progressTotal =
typeof latestExecution.progress?.total === "number" &&
latestExecution.progress.total > 0
? latestExecution.progress.total
: latestExecution.rows > 0
? latestExecution.rows
: rows;
const completedProgress: RecipeExecutionRecord["progress"] = {
...(latestExecution.progress ?? {}),
done: progressTotal,
total: progressTotal,
percent: 100,
eta_sec: 0,
};
const completedColumnProgress: RecipeExecutionRecord["column_progress"] =
latestExecution.column_progress &&
typeof latestExecution.column_progress.total === "number" &&
latestExecution.column_progress.total > 0
? {
...latestExecution.column_progress,
done: latestExecution.column_progress.total,
percent: 100,
eta_sec: 0,
}
: latestExecution.column_progress;
latestExecution = {
...latestExecution,
status: "completed",
progress: completedProgress,
column_progress: completedColumnProgress,
analysis,
dataset,
datasetTotal,
datasetPage: 1,
datasetPageSize: DATASET_PAGE_SIZE,
error: null,
finishedAt: latestExecution.finishedAt ?? Date.now(),
};
upsertAndPersist(latestExecution);
if (notify) {
if (kind === "preview") {
setPreviewErrors([]);
onPreviewSuccess?.();
toastSuccess(`Preview generated (${rows} rows).`);
} else {
toastSuccess("Full run completed.");
}
}
return true;
}
if (lastStatus === "cancelled") {
latestExecution = {
...latestExecution,
status: "cancelled",
error: latestExecution.error ?? "Run cancelled.",
finishedAt: latestExecution.finishedAt ?? Date.now(),
};
upsertAndPersist(latestExecution);
if (notify) {
toastError(`${label} cancelled`, "The execution was cancelled.");
}
return false;
}
latestExecution = {
...latestExecution,
status: "error",
error: latestExecution.error ?? `${label} failed.`,
finishedAt: latestExecution.finishedAt ?? Date.now(),
};
upsertAndPersist(latestExecution);
if (notify) {
toastError(`${label} failed`, latestExecution.error ?? "Execution failed.");
}
return false;
},
[onPreviewSuccess, setPreviewErrors, upsertAndPersist],
);
useEffect(() => {
let cancelled = false;
resetForRecipe();
async function loadExecutions(): Promise<void> {
try {
const records = await listRecipeExecutions(recipeId);
if (cancelled) {
return;
}
const sortedRecords = sortExecutions(records.map(withExecutionDefaults));
setExecutions(sortedRecords);
const activeExecution = sortedRecords.find(
(record) => record.jobId && isExecutionInProgress(record.status),
);
if (activeExecution?.jobId) {
void trackExecution({
label: executionLabel(activeExecution.kind),
kind: activeExecution.kind,
rows: activeExecution.rows,
jobId: activeExecution.jobId,
initialExecution: activeExecution,
notify: false,
});
}
} catch (error) {
console.error("Load recipe executions failed:", error);
}
}
void loadExecutions();
return () => {
cancelled = true;
};
}, [recipeId, resetForRecipe, setExecutions, trackExecution]);
const readPayload = useCallback((): RecipePayload | null => {
if (payloadResult.errors.length === 0) {
return payloadResult.payload;
}
return null;
}, [payloadResult.errors.length, payloadResult.payload]);
const openPreviewDialog = useCallback((): void => {
setPreviewErrors([]);
setPreviewDialogOpen(true);
}, [setPreviewDialogOpen, setPreviewErrors]);
const runExecution = useCallback(
async (input: {
kind: "preview" | "full";
payload: RecipePayload;
rows: number;
}): Promise<boolean> => {
const { kind, payload, rows } = input;
const setLoading = kind === "preview" ? setPreviewLoading : setFullLoading;
const label = executionLabel(kind);
setLoading(true);
const baseExecution = createBaseExecution({
recipeId,
kind,
rows,
currentSignature,
});
upsertAndPersist(baseExecution);
onExecutionStart?.();
if (kind === "preview") {
setPreviewDialogOpen(false);
}
try {
const jobPayload = {
...payload,
run: {
...payload.run,
rows,
// biome-ignore lint/style/useNamingConvention: backend schema
execution_type: kind,
},
};
const createdJob = await createRecipeJob(jobPayload);
const latestExecution = {
...baseExecution,
jobId: createdJob.job_id,
};
upsertAndPersist(latestExecution);
return await trackExecution({
label,
kind,
rows,
jobId: createdJob.job_id,
initialExecution: latestExecution,
notify: true,
});
} catch (error) {
const message = toErrorMessage(error, `${label} request failed.`);
upsertAndPersist({
...baseExecution,
status: "error",
error: message,
finishedAt: Date.now(),
});
if (kind === "preview") {
setPreviewErrors([message]);
}
toastError(`${label} failed`, message);
return false;
} finally {
setLoading(false);
}
},
[
currentSignature,
onExecutionStart,
recipeId,
setFullLoading,
setPreviewDialogOpen,
setPreviewErrors,
setPreviewLoading,
trackExecution,
upsertAndPersist,
],
);
const runPreview = useCallback(async (): Promise<boolean> => {
const payload = readPayload();
const payloadErrorMessage = payloadResult.errors[0] ?? "Invalid payload.";
if (!payload) {
setPreviewErrors(payloadResult.errors);
toastError("Invalid recipe payload", payloadErrorMessage);
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]);
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,
});
}, [payloadResult.errors, previewRows, readPayload, runExecution, setPreviewErrors]);
const runFull = useCallback(async (): Promise<boolean> => {
const payload = readPayload();
const payloadErrorMessage = payloadResult.errors[0] ?? "Invalid payload.";
if (!payload) {
setPreviewErrors(payloadResult.errors);
toastError("Invalid recipe payload", payloadErrorMessage);
return false;
}
const requestedRows = Number(payload.run?.rows);
const rows =
Number.isFinite(requestedRows) && requestedRows > 0
? Math.floor(requestedRows)
: 1000;
return runExecution({
kind: "full",
payload,
rows,
});
}, [payloadResult.errors, readPayload, runExecution, setPreviewErrors]);
const cancelExecution = useCallback(
async (id: string): Promise<void> => {
const execution = executions.find((entry) => entry.id === id);
if (!execution?.jobId) {
return;
}
try {
await cancelRecipeJob(execution.jobId);
upsertAndPersist({
...execution,
status: "cancelling",
});
} catch (error) {
const message = toErrorMessage(error, "Could not cancel execution.");
toastError("Cancel failed", message);
}
},
[executions, upsertAndPersist],
);
const loadExecutionDatasetPage = useCallback(
async (id: string, page: number): Promise<void> => {
const execution = executions.find((entry) => entry.id === id);
if (!execution || execution.kind !== "full" || !execution.jobId) {
return;
}
if (page < 1) {
return;
}
const pageSize = execution.datasetPageSize || DATASET_PAGE_SIZE;
const offset = (page - 1) * pageSize;
try {
const response = await getRecipeJobDataset(execution.jobId, {
limit: pageSize,
offset,
});
const dataset = normalizeDatasetRows(response.dataset);
const total =
typeof response.total === "number" ? response.total : execution.datasetTotal;
upsertAndPersist({
...execution,
dataset,
datasetTotal: total,
datasetPage: page,
});
} catch (error) {
const message = toErrorMessage(error, "Could not load dataset page.");
toastError("Dataset page failed", message);
}
},
[executions, upsertAndPersist],
);
const setSelectedExecutionId = useCallback(
(id: string): void => {
selectExecution(id);
},
[selectExecution],
);
return {
previewDialogOpen,
setPreviewDialogOpen,
previewRows,
setPreviewRows,
previewErrors,
previewLoading,
fullLoading,
executions,
selectedExecutionId,
setSelectedExecutionId,
openPreviewDialog,
runPreview,
runFull,
cancelExecution,
loadExecutionDatasetPage,
};
}

View file

@ -0,0 +1,191 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { toastError, toastSuccess } from "@/shared/toast";
import { normalizeNonEmptyName } from "@/utils";
import {
buildSignature,
copyTextToClipboard,
formatSavedLabel,
} from "../executions/execution-helpers";
import { importRecipePayload, type RecipeSnapshot } from "../utils/import";
import type { RecipePayloadResult } from "../utils/payload/types";
type SaveTone = "success" | "error";
type PersistRecipeFn = (input: {
id: string | null;
name: string;
payload: RecipePayloadResult["payload"];
}) => Promise<{
id: string;
updatedAt: number;
}>;
type UseRecipePersistenceParams = {
recipeId: string;
initialRecipeName: string;
initialPayload: RecipePayloadResult["payload"];
initialSavedAt: number;
payloadResult: RecipePayloadResult;
onPersistRecipe: PersistRecipeFn;
resetRecipe: () => void;
loadRecipe: (snapshot: RecipeSnapshot) => void;
getCurrentPayloadFromStore: () => RecipePayloadResult["payload"];
};
type UseRecipePersistenceResult = {
workflowName: string;
setWorkflowName: (value: string) => void;
saveLoading: boolean;
saveTone: SaveTone;
savedAtLabel: string;
copied: boolean;
importOpen: boolean;
setImportOpen: (open: boolean) => void;
currentSignature: string;
persistRecipe: () => Promise<void>;
copyRecipe: () => Promise<void>;
importRecipe: (value: string) => string | null;
};
export function useRecipePersistence({
recipeId,
initialRecipeName,
initialPayload,
initialSavedAt,
payloadResult,
onPersistRecipe,
resetRecipe,
loadRecipe,
getCurrentPayloadFromStore,
}: UseRecipePersistenceParams): UseRecipePersistenceResult {
const [workflowName, setWorkflowName] = useState("Unnamed");
const [lastSavedAt, setLastSavedAt] = useState<number | null>(null);
const [savedSignature, setSavedSignature] = useState("");
const [saveLoading, setSaveLoading] = useState(false);
const [copied, setCopied] = useState(false);
const [importOpen, setImportOpen] = useState(false);
const normalizedWorkflowName = useMemo(
() => normalizeNonEmptyName(workflowName, "Unnamed"),
[workflowName],
);
const currentPayload = payloadResult.payload;
const currentSignature = useMemo(
() => buildSignature(normalizedWorkflowName, currentPayload),
[currentPayload, normalizedWorkflowName],
);
const isDirty = savedSignature.length > 0 && currentSignature !== savedSignature;
const saveTone: SaveTone = !isDirty && Boolean(lastSavedAt) ? "success" : "error";
const savedAtLabel = formatSavedLabel(lastSavedAt);
const payloadErrorMessage = payloadResult.errors[0] ?? "Invalid payload.";
useEffect(() => {
const nextName = normalizeNonEmptyName(initialRecipeName, "Unnamed");
resetRecipe();
setWorkflowName(nextName);
setLastSavedAt(initialSavedAt);
setCopied(false);
const parsed = importRecipePayload(JSON.stringify(initialPayload));
if (parsed.snapshot) {
loadRecipe(parsed.snapshot);
} else {
console.error("Failed to load recipe payload.", parsed.errors);
}
const payload = getCurrentPayloadFromStore();
setSavedSignature(buildSignature(nextName, payload));
}, [
getCurrentPayloadFromStore,
initialPayload,
initialRecipeName,
initialSavedAt,
loadRecipe,
recipeId,
resetRecipe,
]);
const persistRecipe = useCallback(async (): Promise<void> => {
if (saveLoading) {
return;
}
const nextName = normalizeNonEmptyName(workflowName, "Unnamed");
if (nextName !== workflowName) {
setWorkflowName(nextName);
}
setSaveLoading(true);
try {
const result = await onPersistRecipe({
id: recipeId,
name: nextName,
payload: currentPayload,
});
setLastSavedAt(result.updatedAt);
setSavedSignature(buildSignature(nextName, currentPayload));
} catch (error) {
console.error("Save recipe failed:", error);
toastError("Save failed", "Could not save recipe.");
} finally {
setSaveLoading(false);
}
}, [currentPayload, onPersistRecipe, recipeId, saveLoading, workflowName]);
useEffect(() => {
if (!isDirty || saveLoading) {
return;
}
const timeoutId = window.setTimeout(() => {
void persistRecipe();
}, 800);
return () => window.clearTimeout(timeoutId);
}, [isDirty, persistRecipe, saveLoading]);
const copyRecipe = useCallback(async (): Promise<void> => {
setCopied(false);
if (payloadResult.errors.length > 0) {
toastError("Copy failed", payloadErrorMessage);
return;
}
try {
const ok = await copyTextToClipboard(JSON.stringify(payloadResult.payload, null, 2));
if (!ok) {
throw new Error("Clipboard not available.");
}
setCopied(true);
window.setTimeout(() => setCopied(false), 1500);
toastSuccess("Payload copied");
} catch (error) {
console.error("Copy failed:", error);
toastError("Copy failed", "Could not copy payload.");
}
}, [payloadErrorMessage, payloadResult.errors.length, payloadResult.payload]);
const importRecipe = useCallback(
(value: string): string | null => {
const result = importRecipePayload(value);
if (result.errors.length > 0 || !result.snapshot) {
return result.errors[0] ?? "Invalid payload.";
}
loadRecipe(result.snapshot);
toastSuccess("Recipe imported");
return null;
},
[loadRecipe],
);
return {
workflowName,
setWorkflowName,
saveLoading,
saveTone,
savedAtLabel,
copied,
importOpen,
setImportOpen,
currentSignature,
persistRecipe,
copyRecipe,
importRecipe,
};
}

View file

@ -1,37 +1,7 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { toastError, toastSuccess } from "@/shared/toast";
import { normalizeNonEmptyName } from "@/utils";
import {
cancelRecipeJob,
createRecipeJob,
getRecipeJobAnalysis,
getRecipeJobDataset,
getRecipeJobStatus,
streamRecipeJobEvents,
type JobEvent,
validateRecipe,
} from "../api";
import { listRecipeExecutions, saveRecipeExecution } from "../data/executions-db";
import {
buildSignature,
copyTextToClipboard,
DATASET_PAGE_SIZE,
delay,
executionLabel,
formatSavedLabel,
mapJobStatus,
normalizeAnalysis,
normalizeDatasetRows,
normalizeObject,
sortExecutions,
toErrorMessage,
withExecutionDefaults,
} from "../executions/execution-helpers";
import type {
RecipeExecutionRecord,
RecipeExecutionStatus,
} from "../execution-types";
import { importRecipePayload, type RecipeSnapshot } from "../utils/import";
import { useRecipeExecutions } from "./use-recipe-executions";
import { useRecipePersistence } from "./use-recipe-persistence";
import type { RecipeExecutionRecord } from "../execution-types";
import type { RecipeSnapshot } from "../utils/import";
import type { RecipePayload, RecipePayloadResult } from "../utils/payload/types";
type SaveTone = "success" | "error";
@ -89,74 +59,6 @@ type UseRecipeStudioActionsResult = {
importRecipe: (value: string) => string | null;
};
type JobCompletedEventPayload = {
analysis?: unknown;
dataset?: unknown;
// biome-ignore lint/style/useNamingConvention: backend schema
artifact_path?: unknown;
error?: unknown;
type?: unknown;
};
const MAX_LOG_LINES = 1500;
function formatEventTime(ts: unknown): string {
if (typeof ts !== "number" || !Number.isFinite(ts)) {
return new Date().toLocaleTimeString();
}
const ms = ts > 10_000_000_000 ? ts : ts * 1000;
return new Date(ms).toLocaleTimeString();
}
function appendLogLine(lines: string[], nextLine: string): string[] {
const next = [...lines, nextLine];
if (next.length <= MAX_LOG_LINES) {
return next;
}
return next.slice(next.length - MAX_LOG_LINES);
}
function toLogLine(event: JobEvent): string | null {
const eventType =
typeof event.payload.type === "string" ? event.payload.type : event.event;
const ts = formatEventTime(event.payload.ts);
if (eventType === "log") {
const message =
typeof event.payload.message === "string" ? event.payload.message.trim() : "";
if (!message) {
return null;
}
const level =
typeof event.payload.level === "string" && event.payload.level.length > 0
? event.payload.level.toUpperCase()
: "INFO";
return `[${ts}] [${level}] ${message}`;
}
if (eventType === "job.started") {
return `[${ts}] [INFO] Job started`;
}
if (eventType === "job.completed") {
return `[${ts}] [INFO] Job completed`;
}
if (eventType === "job.cancelling") {
return `[${ts}] [WARN] Cancellation requested`;
}
if (eventType === "job.cancelled") {
return `[${ts}] [WARN] Job cancelled`;
}
if (eventType === "job.error") {
const error =
typeof event.payload.error === "string" && event.payload.error.length > 0
? event.payload.error
: "Job failed";
return `[${ts}] [ERROR] ${error}`;
}
return null;
}
export function useRecipeStudioActions({
recipeId,
initialRecipeName,
@ -170,685 +72,53 @@ export function useRecipeStudioActions({
onExecutionStart,
onPreviewSuccess,
}: UseRecipeStudioActionsParams): UseRecipeStudioActionsResult {
const [workflowName, setWorkflowName] = useState("Unnamed");
const [lastSavedAt, setLastSavedAt] = useState<number | null>(null);
const [savedSignature, setSavedSignature] = useState<string>("");
const [saveLoading, setSaveLoading] = useState(false);
const [copied, setCopied] = useState(false);
const [importOpen, setImportOpen] = useState(false);
const [previewDialogOpen, setPreviewDialogOpen] = useState(false);
const [previewRows, setPreviewRows] = useState(5);
const [previewErrors, setPreviewErrors] = useState<string[]>([]);
const [previewLoading, setPreviewLoading] = useState(false);
const [fullLoading, setFullLoading] = useState(false);
const [executions, setExecutions] = useState<RecipeExecutionRecord[]>([]);
const [selectedExecutionId, setSelectedExecutionId] = useState<string | null>(
null,
);
const normalizedWorkflowName = useMemo(
() => normalizeNonEmptyName(workflowName, "Unnamed"),
[workflowName],
);
const currentPayload = payloadResult.payload;
const currentSignature = useMemo(
() => buildSignature(normalizedWorkflowName, currentPayload),
[currentPayload, normalizedWorkflowName],
);
const isDirty = savedSignature.length > 0 && currentSignature !== savedSignature;
const saveTone: SaveTone = !isDirty && Boolean(lastSavedAt) ? "success" : "error";
const savedAtLabel = formatSavedLabel(lastSavedAt);
const payloadErrorMessage = payloadResult.errors[0] ?? "Invalid payload.";
useEffect(() => {
const nextName = normalizeNonEmptyName(initialRecipeName, "Unnamed");
resetRecipe();
setWorkflowName(nextName);
setLastSavedAt(initialSavedAt);
setCopied(false);
setPreviewErrors([]);
setPreviewDialogOpen(false);
setSelectedExecutionId(null);
const parsed = importRecipePayload(JSON.stringify(initialPayload));
if (parsed.snapshot) {
loadRecipe(parsed.snapshot);
} else {
console.error("Failed to load recipe payload.", parsed.errors);
}
const payload = getCurrentPayloadFromStore();
setSavedSignature(buildSignature(nextName, payload));
}, [
getCurrentPayloadFromStore,
initialPayload,
initialRecipeName,
initialSavedAt,
loadRecipe,
const persistence = useRecipePersistence({
recipeId,
initialRecipeName,
initialPayload,
initialSavedAt,
payloadResult,
onPersistRecipe,
resetRecipe,
]);
loadRecipe,
getCurrentPayloadFromStore,
});
useEffect(() => {
let cancelled = false;
setExecutions([]);
setSelectedExecutionId(null);
async function loadExecutions(): Promise<void> {
try {
const records = await listRecipeExecutions(recipeId);
if (cancelled) {
return;
}
const sortedRecords = sortExecutions(records.map(withExecutionDefaults));
setExecutions(sortedRecords);
setSelectedExecutionId(sortedRecords[0]?.id ?? null);
} catch (error) {
console.error("Load recipe executions failed:", error);
}
}
void loadExecutions();
return () => {
cancelled = true;
};
}, [recipeId]);
const upsertExecution = useCallback((record: RecipeExecutionRecord): void => {
const normalizedRecord = withExecutionDefaults(record);
setExecutions((current) => {
const withoutCurrent = current.filter((item) => item.id !== normalizedRecord.id);
return sortExecutions([normalizedRecord, ...withoutCurrent]);
});
setSelectedExecutionId(normalizedRecord.id);
void saveRecipeExecution(normalizedRecord).catch((error) => {
console.error("Save recipe execution failed:", error);
});
}, []);
const persistRecipe = useCallback(async (): Promise<void> => {
if (saveLoading) {
return;
}
const nextName = normalizeNonEmptyName(workflowName, "Unnamed");
if (nextName !== workflowName) {
setWorkflowName(nextName);
}
setSaveLoading(true);
try {
const result = await onPersistRecipe({
id: recipeId,
name: nextName,
payload: currentPayload,
});
setLastSavedAt(result.updatedAt);
setSavedSignature(buildSignature(nextName, currentPayload));
} catch (error) {
console.error("Save recipe failed:", error);
toastError("Save failed", "Could not save recipe.");
} finally {
setSaveLoading(false);
}
}, [currentPayload, onPersistRecipe, recipeId, saveLoading, workflowName]);
useEffect(() => {
if (!isDirty || saveLoading) {
return;
}
const timeoutId = window.setTimeout(() => {
void persistRecipe();
}, 800);
return () => window.clearTimeout(timeoutId);
}, [isDirty, persistRecipe, saveLoading]);
const readPayload = useCallback((): RecipePayload | null => {
if (payloadResult.errors.length === 0) {
return payloadResult.payload;
}
return null;
}, [payloadResult.errors.length, payloadResult.payload]);
function openPreviewDialog(): void {
setPreviewErrors([]);
setPreviewDialogOpen(true);
}
const runJobExecution = useCallback(async (input: {
kind: "preview" | "full";
payload: RecipePayload;
rows: number;
}): Promise<boolean> => {
const { kind, payload, rows } = input;
const setLoading = kind === "preview" ? setPreviewLoading : setFullLoading;
const label = executionLabel(kind);
setLoading(true);
const createdAt = Date.now();
const baseExecution: RecipeExecutionRecord = {
id: crypto.randomUUID(),
recipeId,
jobId: null,
kind,
status: "pending",
rows,
createdAt,
finishedAt: null,
recipeSignature: currentSignature,
stage: "pending",
current_column: null,
progress: null,
column_progress: null,
model_usage: null,
lastEventId: null,
artifact_path: null,
log_lines: [],
dataset: [],
datasetTotal: 0,
datasetPage: 1,
datasetPageSize: DATASET_PAGE_SIZE,
analysis: null,
processor_artifacts: null,
error: null,
};
upsertExecution(baseExecution);
onExecutionStart?.();
if (kind === "preview") {
setPreviewDialogOpen(false);
}
try {
const jobPayload = {
...payload,
run: {
...payload.run,
rows,
// biome-ignore lint/style/useNamingConvention: backend schema
execution_type: kind,
},
};
const createdJob = await createRecipeJob(jobPayload);
const jobId = createdJob.job_id;
let done = false;
let lastStatus: RecipeExecutionStatus = "pending";
let completedEventPayload: JobCompletedEventPayload | null = null;
let latestExecution: RecipeExecutionRecord = {
...baseExecution,
jobId,
};
upsertExecution(latestExecution);
const eventsAbortController = new AbortController();
void streamRecipeJobEvents({
jobId,
signal: eventsAbortController.signal,
onEvent: (event) => {
let changed = false;
if (typeof event.id === "number") {
latestExecution = {
...latestExecution,
lastEventId: event.id,
};
changed = true;
}
const logLine = toLogLine(event);
if (logLine) {
latestExecution = {
...latestExecution,
log_lines: appendLogLine(latestExecution.log_lines, logLine),
};
changed = true;
}
const eventType =
typeof event.payload.type === "string" ? event.payload.type : event.event;
if (eventType === "job.started") {
latestExecution = {
...latestExecution,
status: "active",
};
upsertExecution(latestExecution);
return;
}
if (eventType === "job.completed") {
lastStatus = "completed";
completedEventPayload = event.payload;
done = true;
latestExecution = {
...latestExecution,
status: "completed",
finishedAt: Date.now(),
artifact_path:
typeof event.payload.artifact_path === "string"
? event.payload.artifact_path
: latestExecution.artifact_path,
error: null,
};
upsertExecution(latestExecution);
return;
}
if (eventType === "job.error") {
lastStatus = "error";
done = true;
latestExecution = {
...latestExecution,
status: "error",
finishedAt: Date.now(),
error:
typeof event.payload.error === "string"
? event.payload.error
: latestExecution.error ?? `${label} failed.`,
};
upsertExecution(latestExecution);
return;
}
if (eventType === "job.cancelling") {
latestExecution = {
...latestExecution,
status: "cancelling",
};
upsertExecution(latestExecution);
return;
}
if (changed) {
upsertExecution(latestExecution);
}
},
}).catch(() => {
// polling remains fallback source of truth
});
try {
while (!done) {
const status = await getRecipeJobStatus(jobId);
const mappedStatus = mapJobStatus(status.status);
lastStatus = mappedStatus;
latestExecution = {
...latestExecution,
status: mappedStatus,
rows: status.rows ?? latestExecution.rows,
stage: status.stage ?? latestExecution.stage,
current_column: status.current_column ?? null,
progress: (normalizeObject(status.progress) as RecipeExecutionRecord["progress"]) ?? null,
column_progress:
(normalizeObject(status.column_progress) as RecipeExecutionRecord["column_progress"]) ?? null,
model_usage: normalizeObject(status.model_usage),
artifact_path: status.artifact_path ?? latestExecution.artifact_path,
error: status.error ?? null,
finishedAt:
mappedStatus === "completed" ||
mappedStatus === "error" ||
mappedStatus === "cancelled"
? Date.now()
: null,
};
upsertExecution(latestExecution);
done =
mappedStatus === "completed" ||
mappedStatus === "error" ||
mappedStatus === "cancelled";
if (!done) {
await delay(1200);
}
}
} finally {
eventsAbortController.abort();
}
if (lastStatus === "completed") {
for (let attempt = 0; attempt < 3; attempt += 1) {
try {
const finalStatus = await getRecipeJobStatus(jobId);
latestExecution = {
...latestExecution,
status: mapJobStatus(finalStatus.status),
rows: finalStatus.rows ?? latestExecution.rows,
stage: finalStatus.stage ?? latestExecution.stage,
current_column: finalStatus.current_column ?? latestExecution.current_column,
progress:
(normalizeObject(finalStatus.progress) as RecipeExecutionRecord["progress"]) ??
latestExecution.progress,
column_progress:
(normalizeObject(
finalStatus.column_progress,
) as RecipeExecutionRecord["column_progress"]) ??
latestExecution.column_progress,
model_usage: normalizeObject(finalStatus.model_usage) ?? latestExecution.model_usage,
artifact_path: finalStatus.artifact_path ?? latestExecution.artifact_path,
error: finalStatus.error ?? latestExecution.error,
finishedAt: latestExecution.finishedAt ?? Date.now(),
};
} catch {
break;
}
if (attempt < 2) {
await delay(250);
}
}
const eventAnalysis = completedEventPayload
? completedEventPayload["analysis"]
: null;
const eventDataset = completedEventPayload
? completedEventPayload["dataset"]
: null;
const shouldFetchPreviewDataset =
kind === "preview" && !Array.isArray(eventDataset);
const shouldFetchAnalysis =
!completedEventPayload ||
typeof eventAnalysis !== "object" ||
eventAnalysis === null ||
kind === "full";
const [analysisResult, datasetResult] = await Promise.allSettled([
shouldFetchAnalysis
? getRecipeJobAnalysis(jobId)
: Promise.resolve(eventAnalysis),
shouldFetchPreviewDataset || kind === "full"
? getRecipeJobDataset(jobId, { limit: DATASET_PAGE_SIZE, offset: 0 })
: Promise.resolve({ dataset: eventDataset ?? [], total: rows }),
]);
const analysis =
analysisResult.status === "fulfilled"
? normalizeAnalysis(analysisResult.value)
: latestExecution.analysis;
const datasetResponse =
datasetResult.status === "fulfilled"
? datasetResult.value
: null;
const dataset = datasetResponse
? normalizeDatasetRows(datasetResponse.dataset)
: latestExecution.dataset;
const datasetTotal =
datasetResponse && typeof datasetResponse.total === "number"
? datasetResponse.total
: latestExecution.datasetTotal;
const progressTotal =
typeof latestExecution.progress?.total === "number" &&
latestExecution.progress.total > 0
? latestExecution.progress.total
: latestExecution.rows > 0
? latestExecution.rows
: rows;
const completedProgress: RecipeExecutionRecord["progress"] = {
...(latestExecution.progress ?? {}),
done: progressTotal,
total: progressTotal,
percent: 100,
eta_sec: 0,
};
const completedColumnProgress: RecipeExecutionRecord["column_progress"] =
latestExecution.column_progress &&
typeof latestExecution.column_progress.total === "number" &&
latestExecution.column_progress.total > 0
? {
...latestExecution.column_progress,
done: latestExecution.column_progress.total,
percent: 100,
eta_sec: 0,
}
: latestExecution.column_progress;
upsertExecution({
...latestExecution,
status: "completed",
progress: completedProgress,
column_progress: completedColumnProgress,
analysis,
dataset,
datasetTotal,
datasetPage: 1,
datasetPageSize: DATASET_PAGE_SIZE,
error: null,
finishedAt: latestExecution.finishedAt ?? Date.now(),
});
if (kind === "preview") {
setPreviewErrors([]);
onPreviewSuccess?.();
toastSuccess(`Preview generated (${rows} rows).`);
} else {
toastSuccess("Full run completed.");
}
return true;
}
if (lastStatus === "cancelled") {
upsertExecution({
...latestExecution,
status: "cancelled",
error: latestExecution.error ?? "Run cancelled.",
finishedAt: latestExecution.finishedAt ?? Date.now(),
});
toastError(`${label} cancelled`, "The execution was cancelled.");
return false;
}
upsertExecution({
...latestExecution,
status: "error",
error: latestExecution.error ?? `${label} failed.`,
finishedAt: latestExecution.finishedAt ?? Date.now(),
});
toastError(`${label} failed`, latestExecution.error ?? "Execution failed.");
return false;
} catch (error) {
const message = toErrorMessage(error, `${label} request failed.`);
upsertExecution({
...baseExecution,
status: "error",
error: message,
finishedAt: Date.now(),
});
if (kind === "preview") {
setPreviewErrors([message]);
}
toastError(`${label} failed`, message);
return false;
} finally {
setLoading(false);
}
}, [
currentSignature,
const executions = useRecipeExecutions({
recipeId,
currentSignature: persistence.currentSignature,
payloadResult,
onExecutionStart,
onPreviewSuccess,
recipeId,
upsertExecution,
]);
const runPreview = useCallback(async (): Promise<boolean> => {
const payload = readPayload();
if (!payload) {
setPreviewErrors(payloadResult.errors);
toastError("Invalid recipe payload", payloadErrorMessage);
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]);
return false;
}
} catch (error) {
const message = toErrorMessage(error, "Validation failed.");
setPreviewErrors([message]);
toastError("Validation failed", message);
return false;
}
return runJobExecution({
kind: "preview",
payload,
rows: previewRows,
});
}, [
payloadErrorMessage,
payloadResult.errors,
previewRows,
readPayload,
runJobExecution,
]);
const runFull = useCallback(async (): Promise<boolean> => {
const payload = readPayload();
if (!payload) {
setPreviewErrors(payloadResult.errors);
toastError("Invalid recipe payload", payloadErrorMessage);
return false;
}
const requestedRows = Number(payload.run?.rows);
const rows = Number.isFinite(requestedRows) && requestedRows > 0
? Math.floor(requestedRows)
: 1000;
return runJobExecution({
kind: "full",
payload,
rows,
});
}, [
payloadErrorMessage,
payloadResult.errors,
readPayload,
runJobExecution,
]);
const cancelExecution = useCallback(async (id: string): Promise<void> => {
const execution = executions.find((entry) => entry.id === id);
if (!execution?.jobId) {
return;
}
try {
await cancelRecipeJob(execution.jobId);
upsertExecution({
...execution,
status: "cancelling",
});
} catch (error) {
const message = toErrorMessage(error, "Could not cancel execution.");
toastError("Cancel failed", message);
}
}, [executions, upsertExecution]);
const loadExecutionDatasetPage = useCallback(
async (id: string, page: number): Promise<void> => {
const execution = executions.find((entry) => entry.id === id);
if (!execution || execution.kind !== "full" || !execution.jobId) {
return;
}
if (page < 1) {
return;
}
const pageSize = execution.datasetPageSize || DATASET_PAGE_SIZE;
const offset = (page - 1) * pageSize;
try {
const response = await getRecipeJobDataset(execution.jobId, {
limit: pageSize,
offset,
});
const dataset = normalizeDatasetRows(response.dataset);
const total =
typeof response.total === "number" ? response.total : execution.datasetTotal;
upsertExecution({
...execution,
dataset,
datasetTotal: total,
datasetPage: page,
});
} catch (error) {
const message = toErrorMessage(error, "Could not load dataset page.");
toastError("Dataset page failed", message);
}
},
[executions, upsertExecution],
);
const selectExecution = useCallback((id: string): void => {
setSelectedExecutionId(id);
}, []);
const copyRecipe = useCallback(async (): Promise<void> => {
setCopied(false);
const payload = readPayload();
if (!payload) {
toastError("Copy failed", payloadErrorMessage);
return;
}
try {
const ok = await copyTextToClipboard(JSON.stringify(payload, null, 2));
if (!ok) {
throw new Error("Clipboard not available.");
}
setCopied(true);
window.setTimeout(() => setCopied(false), 1500);
toastSuccess("Payload copied");
} catch (error) {
console.error("Copy failed:", error);
toastError("Copy failed", "Could not copy payload.");
}
}, [payloadErrorMessage, readPayload]);
const importRecipe = useCallback(
(value: string): string | null => {
const result = importRecipePayload(value);
if (result.errors.length > 0 || !result.snapshot) {
return result.errors[0] ?? "Invalid payload.";
}
loadRecipe(result.snapshot);
toastSuccess("Recipe imported");
return null;
},
[loadRecipe],
);
});
return {
workflowName,
setWorkflowName,
saveLoading,
saveTone,
savedAtLabel,
copied,
importOpen,
setImportOpen,
previewDialogOpen,
setPreviewDialogOpen,
previewRows,
setPreviewRows,
previewErrors,
previewLoading,
fullLoading,
currentSignature,
executions,
selectedExecutionId,
setSelectedExecutionId: selectExecution,
persistRecipe,
openPreviewDialog,
runPreview,
runFull,
cancelExecution,
loadExecutionDatasetPage,
copyRecipe,
importRecipe,
workflowName: persistence.workflowName,
setWorkflowName: persistence.setWorkflowName,
saveLoading: persistence.saveLoading,
saveTone: persistence.saveTone,
savedAtLabel: persistence.savedAtLabel,
copied: persistence.copied,
importOpen: persistence.importOpen,
setImportOpen: persistence.setImportOpen,
previewDialogOpen: executions.previewDialogOpen,
setPreviewDialogOpen: executions.setPreviewDialogOpen,
previewRows: executions.previewRows,
setPreviewRows: executions.setPreviewRows,
previewErrors: executions.previewErrors,
previewLoading: executions.previewLoading,
fullLoading: executions.fullLoading,
currentSignature: persistence.currentSignature,
executions: executions.executions,
selectedExecutionId: executions.selectedExecutionId,
setSelectedExecutionId: executions.setSelectedExecutionId,
persistRecipe: persistence.persistRecipe,
openPreviewDialog: executions.openPreviewDialog,
runPreview: executions.runPreview,
runFull: executions.runFull,
cancelExecution: executions.cancelExecution,
loadExecutionDatasetPage: executions.loadExecutionDatasetPage,
copyRecipe: persistence.copyRecipe,
importRecipe: persistence.importRecipe,
};
}

View file

@ -160,6 +160,12 @@ export function RecipeStudioPage({
const [activeView, setActiveView] = useState<RecipeStudioView>("editor");
const [processorsOpen, setProcessorsOpen] = useState(false);
const [interactive, setInteractive] = useState(true);
const handleExecutionStart = useCallback(() => {
setActiveView("executions");
}, []);
const handlePreviewSuccess = useCallback(() => {
setActiveView("executions");
}, []);
const baseNodeIds = useMemo(
() => new Set(nodes.map((node) => node.id)),
@ -295,12 +301,8 @@ export function RecipeStudioPage({
resetRecipe,
loadRecipe,
getCurrentPayloadFromStore,
onExecutionStart: () => {
setActiveView("executions");
},
onPreviewSuccess: () => {
setActiveView("executions");
},
onExecutionStart: handleExecutionStart,
onPreviewSuccess: handlePreviewSuccess,
});
const openProcessorsFromSheet = useCallback(() => {
@ -324,6 +326,7 @@ export function RecipeStudioPage({
<RecipeStudioHeader
activeView={activeView}
previewLoading={previewLoading}
fullLoading={fullLoading}
saveLoading={saveLoading}
saveTone={saveTone}
savedAtLabel={savedAtLabel}
@ -331,6 +334,9 @@ export function RecipeStudioPage({
onWorkflowNameChange={setWorkflowName}
onViewChange={setActiveView}
onPreview={openPreviewDialog}
onRunFull={() => {
void runFull();
}}
onSaveRecipe={() => {
void persistRecipe();
}}
@ -396,13 +402,7 @@ export function RecipeStudioPage({
executions={executions}
selectedExecutionId={selectedExecutionId}
currentSignature={currentSignature}
previewLoading={previewLoading}
fullLoading={fullLoading}
onSelectExecution={setSelectedExecutionId}
onRunPreview={openPreviewDialog}
onRunFull={() => {
void runFull();
}}
onCancelExecution={(executionId) => {
void cancelExecution(executionId);
}}

View file

@ -0,0 +1,70 @@
import { create } from "zustand";
import type { RecipeExecutionRecord } from "../execution-types";
import { sortExecutions, withExecutionDefaults } from "../executions/execution-helpers";
type RecipeExecutionsState = {
previewDialogOpen: boolean;
previewRows: number;
previewErrors: string[];
previewLoading: boolean;
fullLoading: boolean;
executions: RecipeExecutionRecord[];
selectedExecutionId: string | null;
setPreviewDialogOpen: (open: boolean) => void;
setPreviewRows: (rows: number) => void;
setPreviewErrors: (errors: string[]) => void;
setPreviewLoading: (loading: boolean) => void;
setFullLoading: (loading: boolean) => void;
setExecutions: (records: RecipeExecutionRecord[]) => void;
upsertExecution: (record: RecipeExecutionRecord) => void;
selectExecution: (id: string | null) => void;
resetForRecipe: () => void;
};
const INITIAL_STATE = {
previewDialogOpen: false,
previewRows: 5,
previewErrors: [],
previewLoading: false,
fullLoading: false,
executions: [],
selectedExecutionId: null,
} satisfies Pick<
RecipeExecutionsState,
| "previewDialogOpen"
| "previewRows"
| "previewErrors"
| "previewLoading"
| "fullLoading"
| "executions"
| "selectedExecutionId"
>;
export const useRecipeExecutionsStore = create<RecipeExecutionsState>((set) => ({
...INITIAL_STATE,
setPreviewDialogOpen: (open) => set({ previewDialogOpen: open }),
setPreviewRows: (rows) =>
set({ previewRows: Number.isFinite(rows) && rows > 0 ? Math.floor(rows) : 1 }),
setPreviewErrors: (errors) => set({ previewErrors: errors }),
setPreviewLoading: (loading) => set({ previewLoading: loading }),
setFullLoading: (loading) => set({ fullLoading: loading }),
setExecutions: (records) =>
set(() => {
const normalized = sortExecutions(records.map(withExecutionDefaults));
return {
executions: normalized,
selectedExecutionId: normalized[0]?.id ?? null,
};
}),
upsertExecution: (record) =>
set((state) => {
const normalized = withExecutionDefaults(record);
const withoutCurrent = state.executions.filter((item) => item.id !== normalized.id);
return {
executions: sortExecutions([normalized, ...withoutCurrent]),
selectedExecutionId: normalized.id,
};
}),
selectExecution: (id) => set({ selectedExecutionId: id }),
resetForRecipe: () => set(INITIAL_STATE),
}));