feat: add recipe execution stores, hooks, and logic for managing preview and full executions
This commit is contained in:
parent
4c19a2330a
commit
e0d65bb1cf
8 changed files with 1125 additions and 831 deletions
|
|
@ -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={() =>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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") {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}));
|
||||
Loading…
Add table
Add a link
Reference in a new issue