fix: add missing is_pinned param to ScriptsRepository.create_workflow_script() (#5318)

This commit is contained in:
pedrohsdb 2026-03-31 15:58:27 -07:00 committed by GitHub
parent 02bfcda30f
commit 3cb3beaa33
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
25 changed files with 2663 additions and 361 deletions

View file

@ -441,31 +441,6 @@ export type WorkflowRunApiResponse = {
workflow_title: string | null;
};
export const TaskRunType = {
TaskV1: "task_v1",
TaskV2: "task_v2",
WorkflowRun: "workflow_run",
OpenaiCua: "openai_cua",
AnthropicCua: "anthropic_cua",
UiTars: "ui_tars",
} as const;
export type TaskRunType = (typeof TaskRunType)[keyof typeof TaskRunType];
export type TaskRunListItem = {
task_run_id: string;
task_run_type: TaskRunType;
run_id: string;
title: string | null;
status: string;
started_at: string | null;
finished_at: string | null;
created_at: string;
workflow_permanent_id: string | null;
script_run: boolean;
searchable_text: string | null;
};
export type WorkflowRunStatusApiResponse = {
workflow_id: string;
workflow_run_id: string;

View file

@ -1,9 +1,9 @@
import { getClient } from "@/api/AxiosClient";
import { useCredentialGetter } from "@/hooks/useCredentialGetter";
import { useQuery } from "@tanstack/react-query";
import { Status, TaskRunListItem } from "@/api/types";
import { Status, Task, TriggerType, WorkflowRunApiResponse } from "@/api/types";
type QueryReturnType = Array<TaskRunListItem>;
type QueryReturnType = Array<Task | WorkflowRunApiResponse>;
type UseQueryOptions = Omit<
Parameters<typeof useQuery<QueryReturnType>>[0],
"queryKey" | "queryFn"
@ -13,6 +13,7 @@ type Props = {
page?: number;
pageSize?: number;
statusFilters?: Array<Status>;
triggerTypeFilters?: Array<TriggerType>;
search?: string;
} & UseQueryOptions;
@ -20,14 +21,20 @@ function useRunsQuery({
page = 1,
pageSize = 10,
statusFilters,
triggerTypeFilters,
search,
...queryOptions
}: Props) {
const credentialGetter = useCredentialGetter();
return useQuery<Array<TaskRunListItem>>({
queryKey: ["runs", { statusFilters }, page, pageSize, search],
return useQuery<Array<Task | WorkflowRunApiResponse>>({
queryKey: [
"runs",
{ statusFilters, triggerTypeFilters },
page,
pageSize,
search,
],
queryFn: async () => {
const client = await getClient(credentialGetter, "sans-api-v1");
const client = await getClient(credentialGetter);
const params = new URLSearchParams();
params.append("page", String(page));
params.append("page_size", String(pageSize));
@ -36,12 +43,16 @@ function useRunsQuery({
params.append("status", status);
});
}
if (triggerTypeFilters) {
triggerTypeFilters.forEach((triggerType) => {
params.append("trigger_type", triggerType);
});
}
if (search) {
params.append("search_key", search);
}
return client.get("/runs", { params }).then((res) => res.data);
},
...queryOptions,
});
}

View file

@ -3,12 +3,15 @@ import { LightningBoltIcon, MixerHorizontalIcon } from "@radix-ui/react-icons";
import { Tip } from "@/components/Tip";
import {
Status,
TaskRunListItem,
TaskRunType,
Task,
TriggerType,
WorkflowRunApiResponse,
WorkflowRunStatusApiResponse,
} from "@/api/types";
import { StatusBadge } from "@/components/StatusBadge";
import { StatusFilterDropdown } from "@/components/StatusFilterDropdown";
import { TriggerTypeBadge } from "@/components/TriggerTypeBadge";
import { TriggerTypeFilterDropdown } from "@/components/TriggerTypeFilterDropdown";
import {
Pagination,
PaginationContent,
@ -38,6 +41,7 @@ import React, { useEffect, useState } from "react";
import { useNavigate, useSearchParams } from "react-router-dom";
import { getClient } from "@/api/AxiosClient";
import { useCredentialGetter } from "@/hooks/useCredentialGetter";
import * as env from "@/util/env";
import { useDebounce } from "use-debounce";
import { Button } from "@/components/ui/button";
import {
@ -53,33 +57,21 @@ import { useParameterExpansion } from "@/routes/workflows/hooks/useParameterExpa
import { ParameterDisplayInline } from "@/routes/workflows/components/ParameterDisplayInline";
import { HighlightText } from "@/routes/workflows/components/HighlightText";
const statusValues = new Set<string>(Object.values(Status));
function isKnownStatus(value: string): value is Status {
return statusValues.has(value);
}
function getRunNavigationPath(run: TaskRunListItem): string {
switch (run.task_run_type) {
case TaskRunType.WorkflowRun:
case TaskRunType.TaskV2:
return `/runs/${run.run_id}`;
case TaskRunType.TaskV1:
case TaskRunType.OpenaiCua:
case TaskRunType.AnthropicCua:
case TaskRunType.UiTars:
return `/tasks/${run.run_id}/actions`;
default:
return `/runs/${run.run_id}`;
}
function isTask(run: Task | WorkflowRunApiResponse): run is Task {
return "task_id" in run;
}
function RunHistory() {
const credentialGetter = useCredentialGetter();
const [searchParams, setSearchParams] = useSearchParams();
const page = searchParams.get("page") ? Number(searchParams.get("page")) : 1;
const itemsPerPage = searchParams.get("page_size")
? Number(searchParams.get("page_size"))
: 10;
const [statusFilters, setStatusFilters] = useState<Array<Status>>([]);
const [triggerTypeFilters, setTriggerTypeFilters] = useState<
Array<TriggerType>
>([]);
const [search, setSearch] = useState("");
const [debouncedSearch] = useDebounce(search, 500);
@ -87,17 +79,43 @@ function RunHistory() {
page,
pageSize: itemsPerPage,
statusFilters,
triggerTypeFilters,
search: debouncedSearch,
});
const navigate = useNavigate();
const { data: nextPageRuns } = useRunsQuery({
page: page + 1,
pageSize: itemsPerPage,
statusFilters,
search: debouncedSearch,
enabled: runs?.length === itemsPerPage,
});
const { data: nextPageRuns } = useQuery<Array<Task | WorkflowRunApiResponse>>(
{
queryKey: [
"runs",
{ statusFilters, triggerTypeFilters },
page + 1,
itemsPerPage,
debouncedSearch,
],
queryFn: async () => {
const client = await getClient(credentialGetter);
const params = new URLSearchParams();
params.append("page", String(page + 1));
params.append("page_size", String(itemsPerPage));
if (statusFilters) {
statusFilters.forEach((status) => {
params.append("status", status);
});
}
if (triggerTypeFilters) {
triggerTypeFilters.forEach((triggerType) => {
params.append("trigger_type", triggerType);
});
}
if (debouncedSearch) {
params.append("search_key", debouncedSearch);
}
return client.get("/runs", { params }).then((res) => res.data);
},
enabled: runs && runs.length === itemsPerPage,
},
);
const isNextDisabled =
isFetching || !nextPageRuns || nextPageRuns.length === 0;
@ -118,8 +136,8 @@ function RunHistory() {
const workflowRunIds =
runs
?.filter((run) => run.task_run_type === TaskRunType.WorkflowRun)
.map((run) => run.run_id)
?.filter((run): run is WorkflowRunApiResponse => !isTask(run))
.map((run) => run.workflow_run_id)
.filter((id): id is string => Boolean(id)) ?? [];
setAutoExpandedRows(workflowRunIds);
@ -152,137 +170,12 @@ function RunHistory() {
if (isNextDisabled) return;
setParamPatch({ page: String(page + 1) });
}
const displayTableBody = () => {
// Show loading skeleton
if (isFetching) {
return Array.from({ length: 10 }).map((_, index) => (
<TableRow key={`row-${index}`}>
<TableCell colSpan={6}>
<Skeleton className="h-4 w-full" />
</TableCell>
</TableRow>
));
}
// No runs found
if (runs?.length === 0) {
return (
<TableRow>
<TableCell colSpan={6}>
<div className="text-center">No runs found</div>
</TableCell>
</TableRow>
);
}
return runs?.map((run) => {
const executionTime = formatExecutionTime(
run.started_at ?? run.created_at,
run.finished_at,
);
const isWorkflowRun = run.task_run_type === TaskRunType.WorkflowRun;
const isExpanded = isWorkflowRun && expandedRows.has(run.run_id);
const navPath = getRunNavigationPath(run);
const titleContent = run.script_run ? (
<div className="flex items-center gap-2">
<Tip content="Ran with code">
<LightningBoltIcon className="text-[gold]" />
</Tip>
<span>{run.title ?? ""}</span>
</div>
) : (
run.title ?? ""
);
return (
<React.Fragment key={run.task_run_id}>
<TableRow
className="cursor-pointer"
onClick={(event) => {
handleNavigate(event, navPath);
}}
>
<TableCell className="max-w-0 truncate" title={run.run_id}>
<HighlightText text={run.run_id} query={debouncedSearch} />
</TableCell>
<TableCell
className="max-w-0 truncate"
title={run.title ?? undefined}
>
{titleContent}
</TableCell>
<TableCell>
{isKnownStatus(run.status) ? (
<StatusBadge status={run.status} />
) : (
<span className="text-sm text-slate-400">{run.status}</span>
)}
</TableCell>
<TableCell
className="max-w-0 truncate"
title={basicTimeFormat(run.created_at)}
>
{basicLocalTimeFormat(run.created_at)}
</TableCell>
<TableCell className="text-slate-400">
{executionTime ?? "-"}
</TableCell>
<TableCell>
{isWorkflowRun ? (
<div className="flex justify-end">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="icon"
variant="outline"
onClick={(event) => {
event.stopPropagation();
toggleParametersExpanded(run.run_id);
}}
className={cn(isExpanded && "text-blue-400")}
>
<MixerHorizontalIcon className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
{isExpanded ? "Hide Parameters" : "Show Parameters"}
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
) : null}
</TableCell>
</TableRow>
{isExpanded && run.workflow_permanent_id && (
<TableRow key={`${run.run_id}-params`}>
<TableCell
colSpan={6}
className="bg-slate-50 dark:bg-slate-900/50"
>
<WorkflowRunParametersInline
workflowPermanentId={run.workflow_permanent_id}
workflowRunId={run.run_id}
searchQuery={debouncedSearch}
keywordMatchesParameter={matchesParameter}
/>
</TableCell>
</TableRow>
)}
</React.Fragment>
);
});
};
return (
<div className="space-y-4">
<header>
<h1 className="text-2xl">Run History</h1>
</header>
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-4">
<TableSearchInput
value={search}
onChange={(value) => {
@ -294,10 +187,19 @@ function RunHistory() {
placeholder="Search by run ID or parameter..."
className="w-48 lg:w-72"
/>
<TriggerTypeFilterDropdown
values={triggerTypeFilters}
onChange={(values) => {
setTriggerTypeFilters(values);
const params = new URLSearchParams(searchParams);
params.set("page", "1");
setSearchParams(params, { replace: true });
}}
/>
<StatusFilterDropdown
values={statusFilters}
onChange={(filters) => {
setStatusFilters(filters);
onChange={(values) => {
setStatusFilters(values);
const params = new URLSearchParams(searchParams);
params.set("page", "1");
setSearchParams(params, { replace: true });
@ -320,7 +222,184 @@ function RunHistory() {
<TableHead className="w-[8%] rounded-tr-lg text-slate-400"></TableHead>
</TableRow>
</TableHeader>
<TableBody>{displayTableBody()}</TableBody>
<TableBody>
{isFetching ? (
Array.from({ length: 10 }).map((_, index) => (
<TableRow key={index}>
<TableCell colSpan={6}>
<Skeleton className="h-4 w-full" />
</TableCell>
</TableRow>
))
) : runs?.length === 0 ? (
<TableRow>
<TableCell colSpan={6}>
<div className="text-center">No runs found</div>
</TableCell>
</TableRow>
) : (
runs?.map((run) => {
if (isTask(run)) {
const taskExecutionTime = formatExecutionTime(
run.started_at ?? run.created_at,
run.finished_at,
);
return (
<TableRow
key={run.task_id}
className="cursor-pointer"
onClick={(event) => {
handleNavigate(event, `/tasks/${run.task_id}/actions`);
}}
>
<TableCell className="max-w-0 truncate">
{run.task_id}
</TableCell>
<TableCell className="max-w-0 truncate">
{run.url}
</TableCell>
<TableCell>
<StatusBadge status={run.status} />
</TableCell>
<TableCell
title={basicTimeFormat(run.created_at)}
className="max-w-0 truncate"
>
{basicLocalTimeFormat(run.created_at)}
</TableCell>
<TableCell className="truncate text-slate-400">
{taskExecutionTime ?? "-"}
</TableCell>
{/* Align with workflow row's expand button column. */}
<TableCell />
</TableRow>
);
}
const workflowTitle = (
<div className="flex items-center gap-2">
<span className="truncate">{run.workflow_title ?? ""}</span>
{run.script_run === true && (
<Tip content="Ran with code">
<LightningBoltIcon className="text-[gold]" />
</Tip>
)}
<TriggerTypeBadge triggerType={run.trigger_type} />
</div>
);
const isExpanded = expandedRows.has(run.workflow_run_id);
const workflowExecutionTime = formatExecutionTime(
run.started_at ?? run.created_at,
run.finished_at,
);
return (
<React.Fragment key={run.workflow_run_id}>
<TableRow
className="cursor-pointer"
onClick={(event) => {
handleNavigate(
event,
env.useNewRunsUrl
? `/runs/${run.workflow_run_id}`
: `/workflows/${run.workflow_permanent_id}/${run.workflow_run_id}/overview`,
);
}}
>
<TableCell
className="max-w-0 truncate"
title={run.workflow_run_id}
>
<HighlightText
text={run.workflow_run_id}
query={debouncedSearch}
/>
</TableCell>
<TableCell
className="max-w-0 truncate"
title={run.workflow_title ?? undefined}
>
{workflowTitle}
</TableCell>
<TableCell>
<StatusBadge status={run.status} />
</TableCell>
<TableCell
className="max-w-0 truncate"
title={
run.trigger_type === TriggerType.Scheduled &&
run.scheduled_for
? `Scheduled: ${basicTimeFormat(run.scheduled_for)}\nStarted: ${basicTimeFormat(run.created_at)}`
: basicTimeFormat(run.created_at)
}
>
<div className="flex flex-col">
<span>{basicLocalTimeFormat(run.created_at)}</span>
{run.trigger_type === TriggerType.Scheduled &&
run.schedule_name && (
<span className="text-xs text-slate-400">
{run.schedule_name}
{run.schedule_cron
? ` (${run.schedule_cron})`
: ""}
</span>
)}
</div>
</TableCell>
<TableCell className="truncate text-slate-400">
{workflowExecutionTime ?? "-"}
</TableCell>
<TableCell>
<div className="flex justify-end">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="icon"
variant="outline"
onClick={(event) => {
event.stopPropagation();
toggleParametersExpanded(
run.workflow_run_id,
);
}}
className={cn(isExpanded && "text-blue-400")}
>
<MixerHorizontalIcon className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
{isExpanded
? "Hide Parameters"
: "Show Parameters"}
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</TableCell>
</TableRow>
{isExpanded && (
<TableRow key={`${run.workflow_run_id}-params`}>
<TableCell
colSpan={6}
className="bg-slate-50 dark:bg-slate-900/50"
>
<WorkflowRunParametersInline
workflowPermanentId={run.workflow_permanent_id}
workflowRunId={run.workflow_run_id}
searchQuery={debouncedSearch}
keywordMatchesParameter={matchesParameter}
/>
</TableCell>
</TableRow>
)}
</React.Fragment>
);
})
)}
</TableBody>
</Table>
<div className="relative px-3 py-3">
<div className="absolute left-3 top-1/2 flex -translate-y-1/2 items-center gap-2 text-sm">
@ -387,7 +466,7 @@ function WorkflowRunParametersInline({
workflowRunId,
searchQuery,
keywordMatchesParameter,
}: Readonly<WorkflowRunParametersInlineProps>) {
}: WorkflowRunParametersInlineProps) {
const { data: globalWorkflows } = useGlobalWorkflowsQuery();
const credentialGetter = useCredentialGetter();

View file

@ -1654,6 +1654,27 @@ function getElements(
// Create top-level edges based on next_block_label (not array order!)
// We'll filter out conditional branch blocks below by checking conditionalNodeId
//
// Detect cycles by walking the next_block_label chain (not array order, since the
// two can differ). React Flow crashes when rendering cyclic edge graphs.
const cycleBackEdgeLabels = new Set<string>();
{
const visited = new Set<string>();
let current = blocks[0]?.label ?? null;
while (current && !visited.has(current)) {
visited.add(current);
const block = blocksByLabel.get(current);
if (!block) break;
const next = block.next_block_label ?? null;
if (next && visited.has(next)) {
// This block's next_block_label closes a cycle — mark it
cycleBackEdgeLabels.add(current);
break;
}
current = next;
}
}
blocks.forEach((block) => {
const sourceNode = labelToNode.get(block.label);
if (!sourceNode || !isWorkflowBlockNode(sourceNode)) {
@ -1668,6 +1689,10 @@ function getElements(
// Find target block using next_block_label
const nextLabel = block.next_block_label;
if (nextLabel) {
// Skip edges that close a cycle in the next_block_label chain
if (cycleBackEdgeLabels.has(block.label)) {
return;
}
const targetNode = labelToNode.get(nextLabel);
if (targetNode) {
edges.push(edgeWithAddButton(sourceNode.id, targetNode.id));
@ -1691,11 +1716,13 @@ function getElements(
if (blocks.length === 0) {
edges.push(defaultEdge(startNodeId, adderNodeId));
} else {
// Find the last top-level block (one with next_block_label === null and not in a branch)
// There might be multiple blocks with next_block_label === null (e.g., last block in nested branches)
// We need the one that's NOT inside any conditional
// Find the last top-level block: one with next_block_label === null OR
// one whose back-edge was skipped (cycle broken), and not in a branch
const lastBlock = blocks.find((block) => {
if (block.next_block_label !== null) {
if (
block.next_block_label !== null &&
!cycleBackEdgeLabels.has(block.label)
) {
return false;
}
const node = labelToNode.get(block.label);