From 703170f7843635a3e51fb5b39d04688a77b03554 Mon Sep 17 00:00:00 2001 From: Blake Gentry Date: Sun, 14 Sep 2025 20:04:46 -0500 Subject: [PATCH 1/6] use riverpro v0.18.0, river v0.25.0 --- riverproui/go.mod | 30 +++++++++++++------------- riverproui/go.sum | 55 ++++++++++++++++++++++++----------------------- 2 files changed, 43 insertions(+), 42 deletions(-) diff --git a/riverproui/go.mod b/riverproui/go.mod index 6e593d6..4f37953 100644 --- a/riverproui/go.mod +++ b/riverproui/go.mod @@ -1,6 +1,6 @@ module riverqueue.com/riverui/riverproui -go 1.24 +go 1.24.0 toolchain go1.24.4 @@ -8,14 +8,14 @@ require ( github.com/google/uuid v1.6.0 github.com/jackc/pgx/v5 v5.7.6 github.com/riverqueue/apiframe v0.0.0-20250708014637-e55c49c01ff7 - github.com/riverqueue/river v0.24.0 - github.com/riverqueue/river/riverdriver v0.24.0 - github.com/riverqueue/river/rivershared v0.24.0 - github.com/riverqueue/river/rivertype v0.24.0 + github.com/riverqueue/river v0.25.0 + github.com/riverqueue/river/riverdriver v0.25.0 + github.com/riverqueue/river/rivershared v0.25.0 + github.com/riverqueue/river/rivertype v0.25.0 github.com/stretchr/testify v1.11.1 - riverqueue.com/riverpro v0.16.0 - riverqueue.com/riverpro/driver v0.16.0 - riverqueue.com/riverpro/driver/riverpropgxv5 v0.16.0 + riverqueue.com/riverpro v0.18.0 + riverqueue.com/riverpro/driver v0.18.0 + riverqueue.com/riverpro/driver/riverpropgxv5 v0.18.0 riverqueue.com/riverui v0.12.2 ) @@ -31,21 +31,21 @@ require ( github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/leodido/go-urn v1.4.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect - github.com/riverqueue/river/riverdriver/riverpgxv5 v0.24.0 // indirect + github.com/riverqueue/river/riverdriver/riverpgxv5 v0.25.0 // indirect github.com/rs/cors v1.11.1 // indirect github.com/samber/slog-http v1.8.1 // indirect github.com/tidwall/gjson v1.18.0 // indirect - github.com/tidwall/match v1.1.1 // indirect + github.com/tidwall/match v1.2.0 // indirect github.com/tidwall/pretty v1.2.1 // indirect github.com/tidwall/sjson v1.2.5 // indirect go.opentelemetry.io/otel v1.29.0 // indirect go.opentelemetry.io/otel/trace v1.29.0 // indirect go.uber.org/goleak v1.3.0 // indirect - golang.org/x/crypto v0.41.0 // indirect - golang.org/x/net v0.42.0 // indirect - golang.org/x/sync v0.16.0 // indirect - golang.org/x/sys v0.35.0 // indirect - golang.org/x/text v0.28.0 // indirect + golang.org/x/crypto v0.42.0 // indirect + golang.org/x/net v0.43.0 // indirect + golang.org/x/sync v0.17.0 // indirect + golang.org/x/sys v0.36.0 // indirect + golang.org/x/text v0.29.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/riverproui/go.sum b/riverproui/go.sum index afda4f4..a785c06 100644 --- a/riverproui/go.sum +++ b/riverproui/go.sum @@ -35,16 +35,16 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/riverqueue/apiframe v0.0.0-20250708014637-e55c49c01ff7 h1:A16RTdTAQ2cIY++FPjiQ9yL8/2FXR4wYmaN7SpP3yP0= github.com/riverqueue/apiframe v0.0.0-20250708014637-e55c49c01ff7/go.mod h1:jV49jb/qzxSqwDajmG4N2Cm50KATxblxlSiXMF9Ck1E= -github.com/riverqueue/river v0.24.0 h1:CesL6vymWgz0d+zNwtnSGRWaB+E8Dax+o9cxD7sUmKc= -github.com/riverqueue/river v0.24.0/go.mod h1:UZ3AxU5t6WtyqNssaea/AkRS8h/kJ+E9ImSB3xyb3ns= -github.com/riverqueue/river/riverdriver v0.24.0 h1:HqGgGkls11u+YKDA7cKOdYKlQwRNJyHuGa3UtOvpdT0= -github.com/riverqueue/river/riverdriver v0.24.0/go.mod h1:dEew9DDIKenNvzpm8Edw8+PkqP3c0zl1fKjiQTq2n/w= -github.com/riverqueue/river/riverdriver/riverpgxv5 v0.24.0 h1:yV37OIbRrhRwIiGeRT7P4D3szhAemu87BgCf8gTCoU4= -github.com/riverqueue/river/riverdriver/riverpgxv5 v0.24.0/go.mod h1:QfznySVKC4ljx53syd/bA/LRSsydAyuD3Q9/EbSniKA= -github.com/riverqueue/river/rivershared v0.24.0 h1:KysokksW75pug2a5RTOc6WESOupWmsylVc6VWvAx+4Y= -github.com/riverqueue/river/rivershared v0.24.0/go.mod h1:UIBfSdai0oWFlwFcoqG4DZX83iA/fLWTEBGrj7Oe1ho= -github.com/riverqueue/river/rivertype v0.24.0 h1:xrQZm/h6U8TBPyTsQPYD5leOapuoBAcdz30bdBwTqOg= -github.com/riverqueue/river/rivertype v0.24.0/go.mod h1:lmdl3vLNDfchDWbYdW2uAocIuwIN+ZaXqAukdSCFqWs= +github.com/riverqueue/river v0.25.0 h1:dRnA9ltq9hTYRMmZgBnhqRh3AzBIFVu+qVLpBqy6b+g= +github.com/riverqueue/river v0.25.0/go.mod h1:KetN5MQQu9IjtganQrIt0OFubweeh+qkAqJaCdalwtI= +github.com/riverqueue/river/riverdriver v0.25.0 h1:RkvBWBlybYGaU1DoQ/mSwnWp1hm0FfS8yyksr/dM5tI= +github.com/riverqueue/river/riverdriver v0.25.0/go.mod h1:p2Jvr1N6NfPA+ngIKK8urqxG2vmusX4jO7g/UH/soQY= +github.com/riverqueue/river/riverdriver/riverpgxv5 v0.25.0 h1:Ed6dtSSwsj7VwbquG6Bh+2+271sBOL6WyRbisY/XHiY= +github.com/riverqueue/river/riverdriver/riverpgxv5 v0.25.0/go.mod h1:h77bWaGJyA5GMKEKmANQN9mhsV3XWYt4sRUx6FtQa84= +github.com/riverqueue/river/rivershared v0.25.0 h1:grjuTHJEVvi4srzcspQ2UXWjISxdqbubQl+9DDg3agQ= +github.com/riverqueue/river/rivershared v0.25.0/go.mod h1:ZdVeOnT8X8PiAZRUfWHc+Ne6fNXqe1oYb2eioZb6URM= +github.com/riverqueue/river/rivertype v0.25.0 h1:DPwd0DGqajLIv9zsB+BOwlum0D1/4Iiqz34+nwIZaZ0= +github.com/riverqueue/river/rivertype v0.25.0/go.mod h1:9bbWVYkr1B/YzW43lUs/Vk/tEYqLrabrZWrtUWQ+Goo= github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= @@ -61,8 +61,9 @@ github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= -github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/match v1.2.0 h1:0pt8FlkOwjN2fPt4bIl4BoNxb98gGHN2ObFEDkrfZnM= +github.com/tidwall/match v1.2.0/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= @@ -74,25 +75,25 @@ go.opentelemetry.io/otel/trace v1.29.0 h1:J/8ZNK4XgR7a21DZUAsbF8pZ5Jcw1VhACmnYt3 go.opentelemetry.io/otel/trace v1.29.0/go.mod h1:eHl3w0sp3paPkYstJOmAimxhiFXPg+MMTlEh3nsQgWQ= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= -golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4= -golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= -golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs= -golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8= -golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= -golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= -golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= -golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= -golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= -golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= +golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI= +golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8= +golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= +golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= +golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= +golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= +golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= +golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -riverqueue.com/riverpro v0.16.0 h1:y6w5TaC9gNkIFDZJgDbrnaeiqo5weTSAgWOvHGsoDdk= -riverqueue.com/riverpro v0.16.0/go.mod h1:d70DhPfPjUKy+SkDmPUkU0vuOtLWxNmB4T+sE2w2/Jo= -riverqueue.com/riverpro/driver v0.16.0 h1:yhFnXcX9PDCu3chGuLLR6Jx3EuPYUoK20gFv31DR6c4= -riverqueue.com/riverpro/driver v0.16.0/go.mod h1:MrbBVCcEQWWKeQiDMJEXAP6fuEyN1UqQ5CXd+m4rWrQ= -riverqueue.com/riverpro/driver/riverpropgxv5 v0.16.0 h1:iC+9a7mVVaJH/El4Z/MmzFaxMoIAn+QGEDlFLST0gVs= -riverqueue.com/riverpro/driver/riverpropgxv5 v0.16.0/go.mod h1:TKQGLJyfOwCq133ttA4i8TI90uF4C4zTqEArAHOD37g= +riverqueue.com/riverpro v0.18.0 h1:cGIU7rIeAS2OYRVN5fTtf0SuSwg0AldlGjFdGJGtCpo= +riverqueue.com/riverpro v0.18.0/go.mod h1:7FmhsAcTaL8pwJtIN17aU5VjAnP46zJ3+9RrJvx4pDQ= +riverqueue.com/riverpro/driver v0.18.0 h1:+UxiZn++M9dNX5iDFNPqEQ+67QOA2cqXF7IgpCruR2E= +riverqueue.com/riverpro/driver v0.18.0/go.mod h1:T1G/XM9H/Ccs5FaHICpVXWP0+qN4m8+hruyLRgorKzU= +riverqueue.com/riverpro/driver/riverpropgxv5 v0.18.0 h1:vFuinoXIVetyK2bAs8Dom0+uW2O0vJ0sMDnx8yFQXJI= +riverqueue.com/riverpro/driver/riverpropgxv5 v0.18.0/go.mod h1:oGVzU7TAG9clY9IykAbVE1eugXS+YKh5Pk29K8bvstQ= From b839ff88467e5af560f67080b8305abda13888f4 Mon Sep 17 00:00:00 2001 From: Blake Gentry Date: Sun, 14 Sep 2025 20:58:45 -0500 Subject: [PATCH 2/6] implement workflow retry endpoint --- common_test.go | 3 +- int64_string_test.go | 1 + riverproui/endpoints.go | 1 + .../prohandler/pro_handler_api_endpoints.go | 71 ++++++++++ .../pro_handler_api_endpoints_test.go | 133 ++++++++++++++++++ src/components/WorkflowDetail.tsx | 2 +- 6 files changed, 209 insertions(+), 2 deletions(-) diff --git a/common_test.go b/common_test.go index c332b10..1f0c790 100644 --- a/common_test.go +++ b/common_test.go @@ -6,11 +6,12 @@ import ( "github.com/jackc/pgx/v5" "github.com/stretchr/testify/require" - "riverqueue.com/riverui/internal/uicommontest" "github.com/riverqueue/river" "github.com/riverqueue/river/riverdriver" "github.com/riverqueue/river/riverdriver/riverpgxv5" + + "riverqueue.com/riverui/internal/uicommontest" ) func insertOnlyClient(t *testing.T, logger *slog.Logger) (*river.Client[pgx.Tx], riverdriver.Driver[pgx.Tx]) { diff --git a/int64_string_test.go b/int64_string_test.go index d678ff6..71be745 100644 --- a/int64_string_test.go +++ b/int64_string_test.go @@ -7,6 +7,7 @@ import ( "testing" "github.com/stretchr/testify/require" + "riverqueue.com/riverui/internal/uicommontest" ) diff --git a/riverproui/endpoints.go b/riverproui/endpoints.go index f1e3608..a13d0fd 100644 --- a/riverproui/endpoints.go +++ b/riverproui/endpoints.go @@ -92,6 +92,7 @@ func (e *endpoints[TTx]) MountEndpoints(archetype *baseservice.Archetype, logger apiendpoint.Mount(mux, prohandler.NewWorkflowCancelEndpoint(bundle), mountOpts), apiendpoint.Mount(mux, prohandler.NewWorkflowGetEndpoint(bundle), mountOpts), apiendpoint.Mount(mux, prohandler.NewWorkflowListEndpoint(bundle), mountOpts), + apiendpoint.Mount(mux, prohandler.NewWorkflowRetryEndpoint(bundle), mountOpts), ) return endpoints diff --git a/riverproui/internal/prohandler/pro_handler_api_endpoints.go b/riverproui/internal/prohandler/pro_handler_api_endpoints.go index 05c82d5..52bf6f5 100644 --- a/riverproui/internal/prohandler/pro_handler_api_endpoints.go +++ b/riverproui/internal/prohandler/pro_handler_api_endpoints.go @@ -291,6 +291,77 @@ func (a *workflowListEndpoint[TTx]) Execute(ctx context.Context, req *workflowLi } } +// +// workflowRetryEndpoint +// + +type workflowRetryEndpoint[TTx any] struct { + ProAPIBundle[TTx] + apiendpoint.Endpoint[workflowRetryRequest, workflowRetryResponse] +} + +func NewWorkflowRetryEndpoint[TTx any](apiBundle ProAPIBundle[TTx]) *workflowRetryEndpoint[TTx] { + return &workflowRetryEndpoint[TTx]{ProAPIBundle: apiBundle} +} + +func (*workflowRetryEndpoint[TTx]) Meta() *apiendpoint.EndpointMeta { + return &apiendpoint.EndpointMeta{ + Pattern: "POST /api/pro/workflows/{id}/retry", + StatusCode: http.StatusOK, + } +} + +type workflowRetryRequest struct { + ID string `json:"-" validate:"required"` // from ExtractRaw + Mode string `json:"mode" validate:"omitempty,oneof=all failed_only failed_and_downstream"` + ResetHistory bool `json:"reset_history"` +} + +func (req *workflowRetryRequest) ExtractRaw(r *http.Request) error { + req.ID = r.PathValue("id") + return nil +} + +type workflowRetryResponse struct { + RetriedJobs []*riverJobMinimal `json:"retried_jobs"` +} + +func (a *workflowRetryEndpoint[TTx]) Execute(ctx context.Context, req *workflowRetryRequest) (*workflowRetryResponse, error) { + return dbutil.WithTxV(ctx, a.DB, func(ctx context.Context, execTx riverdriver.ExecutorTx) (*workflowRetryResponse, error) { + tx := a.Driver.UnwrapTx(execTx) + + // Build workflow wrapper from existing workflow ID + workflow := a.Client.NewWorkflow(&riverpro.WorkflowOpts{ID: req.ID}) + + // Determine retry mode (defaults to "all") + var mode riverpro.WorkflowRetryMode + switch req.Mode { + case "failed_only": + mode = riverpro.WorkflowRetryModeFailedOnly + case "failed_and_downstream": + mode = riverpro.WorkflowRetryModeFailedAndDownstream + case "", "all": + mode = riverpro.WorkflowRetryModeAll + default: + // validator should prevent this path; keep safe default + mode = riverpro.WorkflowRetryModeAll + } + + result, err := workflow.RetryTx(ctx, tx, &riverpro.WorkflowRetryOpts{ + Mode: mode, + ResetHistory: req.ResetHistory, + }) + if err != nil { + return nil, err + } + + // consistent ordering + slices.SortFunc(result.Jobs, func(a, b *rivertype.JobRow) int { return int(a.ID - b.ID) }) + + return &workflowRetryResponse{RetriedJobs: sliceutil.Map(result.Jobs, internalJobToJobMinimal)}, nil + }) +} + type riverJobMinimal struct { ID int64 `json:"id"` Args json.RawMessage `json:"args"` diff --git a/riverproui/internal/prohandler/pro_handler_api_endpoints_test.go b/riverproui/internal/prohandler/pro_handler_api_endpoints_test.go index a01cd2b..00048c6 100644 --- a/riverproui/internal/prohandler/pro_handler_api_endpoints_test.go +++ b/riverproui/internal/prohandler/pro_handler_api_endpoints_test.go @@ -5,6 +5,7 @@ import ( "encoding/json" "log/slog" "testing" + "time" "github.com/jackc/pgx/v5" "github.com/stretchr/testify/require" @@ -16,11 +17,13 @@ import ( "github.com/riverqueue/river/riverdriver" "github.com/riverqueue/river/rivershared/riversharedtest" "github.com/riverqueue/river/rivershared/startstop" + "github.com/riverqueue/river/rivershared/util/ptrutil" "github.com/riverqueue/river/rivertype" "riverqueue.com/riverpro" "riverqueue.com/riverpro/driver" "riverqueue.com/riverpro/driver/riverpropgxv5" + "riverqueue.com/riverui/internal/apibundle" "riverqueue.com/riverui/internal/riverinternaltest" "riverqueue.com/riverui/internal/riverinternaltest/testfactory" @@ -119,6 +122,136 @@ func TestProAPIHandlerWorkflowCancel(t *testing.T) { }) } +func TestProAPIHandlerWorkflowRetry(t *testing.T) { + t.Parallel() + + ctx := context.Background() + + t.Run("SuccessDefaultAll", func(t *testing.T) { + t.Parallel() + + endpoint, bundle := setupEndpoint(ctx, t, NewWorkflowRetryEndpoint) + + job1 := testfactory.Job(ctx, t, bundle.exec, &testfactory.JobOpts{ + FinalizedAt: ptrutil.Ptr(time.Now()), + Metadata: workflowMetadata("wf_all_1", "task1", nil), + State: ptrutil.Ptr(rivertype.JobStateDiscarded), + }) + job2 := testfactory.Job(ctx, t, bundle.exec, &testfactory.JobOpts{ + FinalizedAt: ptrutil.Ptr(time.Now()), + Metadata: workflowMetadata("wf_all_1", "task2", nil), + State: ptrutil.Ptr(rivertype.JobStateCompleted), + }) + job3 := testfactory.Job(ctx, t, bundle.exec, &testfactory.JobOpts{ + FinalizedAt: ptrutil.Ptr(time.Now()), + Metadata: workflowMetadata("wf_all_1", "task3", []string{"task1", "task2"}), + State: ptrutil.Ptr(rivertype.JobStateCancelled), + }) + + resp, err := apitest.InvokeHandler(ctx, endpoint.Execute, testMountOpts(t), &workflowRetryRequest{ID: "wf_all_1"}) + require.NoError(t, err) + + require.Len(t, resp.RetriedJobs, 3) + require.Equal(t, []int64{job1.ID, job2.ID, job3.ID}, []int64{resp.RetriedJobs[0].ID, resp.RetriedJobs[1].ID, resp.RetriedJobs[2].ID}) + }) + + t.Run("ModeFailedOnly", func(t *testing.T) { + t.Parallel() + + endpoint, bundle := setupEndpoint(ctx, t, NewWorkflowRetryEndpoint) + + // Build jobs with specific states + jobCompleted := testfactory.Job(ctx, t, bundle.exec, &testfactory.JobOpts{ + FinalizedAt: ptrutil.Ptr(time.Now()), + Metadata: workflowMetadata("wf_failed_only", "done", nil), + State: ptrutil.Ptr(rivertype.JobStateCompleted), + }) + jobDiscarded := testfactory.Job(ctx, t, bundle.exec, &testfactory.JobOpts{ + FinalizedAt: ptrutil.Ptr(time.Now()), + Metadata: workflowMetadata("wf_failed_only", "failed", nil), + State: ptrutil.Ptr(rivertype.JobStateDiscarded), + }) + _ = jobCompleted + + resp, err := apitest.InvokeHandler(ctx, endpoint.Execute, testMountOpts(t), &workflowRetryRequest{ID: "wf_failed_only", Mode: "failed_only"}) + require.NoError(t, err) + + require.Len(t, resp.RetriedJobs, 1) + require.Equal(t, jobDiscarded.ID, resp.RetriedJobs[0].ID) + }) + + t.Run("ModeFailedAndDownstream", func(t *testing.T) { + t.Parallel() + + endpoint, bundle := setupEndpoint(ctx, t, NewWorkflowRetryEndpoint) + + // a -> b -> c; mark a as discarded, others completed + jobA := testfactory.Job(ctx, t, bundle.exec, &testfactory.JobOpts{ + FinalizedAt: ptrutil.Ptr(time.Now()), + Metadata: workflowMetadata("wf_failed_downstream", "a", nil), + State: ptrutil.Ptr(rivertype.JobStateDiscarded), + }) + jobB := testfactory.Job(ctx, t, bundle.exec, &testfactory.JobOpts{ + FinalizedAt: ptrutil.Ptr(time.Now()), + Metadata: workflowMetadata("wf_failed_downstream", "b", []string{"a"}), + State: ptrutil.Ptr(rivertype.JobStateCompleted), + }) + jobC := testfactory.Job(ctx, t, bundle.exec, &testfactory.JobOpts{ + FinalizedAt: ptrutil.Ptr(time.Now()), + Metadata: workflowMetadata("wf_failed_downstream", "c", []string{"b"}), + State: ptrutil.Ptr(rivertype.JobStateCompleted), + }) + + resp, err := apitest.InvokeHandler(ctx, endpoint.Execute, testMountOpts(t), &workflowRetryRequest{ID: "wf_failed_downstream", Mode: "failed_and_downstream"}) + require.NoError(t, err) + + require.Len(t, resp.RetriedJobs, 3) + require.Equal(t, []int64{jobA.ID, jobB.ID, jobC.ID}, []int64{resp.RetriedJobs[0].ID, resp.RetriedJobs[1].ID, resp.RetriedJobs[2].ID}) + }) + + t.Run("ResetHistoryBehavior", func(t *testing.T) { + t.Parallel() + + endpoint, bundle := setupEndpoint(ctx, t, NewWorkflowRetryEndpoint) + + attempt := 2 + maxAttempts := 5 + job := testfactory.Job(ctx, t, bundle.exec, &testfactory.JobOpts{ + Attempt: &attempt, + FinalizedAt: ptrutil.Ptr(time.Now()), + Metadata: workflowMetadata("wf_reset_history", "t1", nil), + MaxAttempts: func() *int { v := maxAttempts; return &v }(), + State: ptrutil.Ptr(rivertype.JobStateCompleted), + }) + + // Without resetting history, Attempt stays the same and MaxAttempts increments by 1 + respNoReset, err := apitest.InvokeHandler(ctx, endpoint.Execute, testMountOpts(t), &workflowRetryRequest{ID: "wf_reset_history", ResetHistory: false}) + require.NoError(t, err) + require.Len(t, respNoReset.RetriedJobs, 1) + require.Equal(t, job.ID, respNoReset.RetriedJobs[0].ID) + require.Equal(t, attempt, respNoReset.RetriedJobs[0].Attempt) + require.Equal(t, maxAttempts+1, respNoReset.RetriedJobs[0].MaxAttempts) + + // With resetting history, Attempt resets to 0 and MaxAttempts does not increment beyond the previous +1 action + // Create a fresh workflow to isolate effects + attempt2 := 3 + job2 := testfactory.Job(ctx, t, bundle.exec, &testfactory.JobOpts{ + Attempt: &attempt2, + FinalizedAt: ptrutil.Ptr(time.Now()), + Metadata: workflowMetadata("wf_reset_history2", "t1", nil), + MaxAttempts: func() *int { v := maxAttempts; return &v }(), + State: ptrutil.Ptr(rivertype.JobStateCompleted), + }) + + respReset, err := apitest.InvokeHandler(ctx, endpoint.Execute, testMountOpts(t), &workflowRetryRequest{ID: "wf_reset_history2", ResetHistory: true}) + require.NoError(t, err) + require.Len(t, respReset.RetriedJobs, 1) + require.Equal(t, job2.ID, respReset.RetriedJobs[0].ID) + require.Equal(t, 0, respReset.RetriedJobs[0].Attempt) + require.Equal(t, maxAttempts, respReset.RetriedJobs[0].MaxAttempts) + }) +} + func makeWorkflowJob(ctx context.Context, t *testing.T, exec riverdriver.ExecutorTx, workflowID string, taskName string, deps []string) *rivertype.JobRow { t.Helper() diff --git a/src/components/WorkflowDetail.tsx b/src/components/WorkflowDetail.tsx index b915d8d..e720b4a 100644 --- a/src/components/WorkflowDetail.tsx +++ b/src/components/WorkflowDetail.tsx @@ -121,7 +121,7 @@ export default function WorkflowDetail({ )} From e243be6dccc52c3588590215085b272835862c83 Mon Sep 17 00:00:00 2001 From: Blake Gentry Date: Sun, 14 Sep 2025 21:22:01 -0500 Subject: [PATCH 3/6] expose workflow retry on frontend --- src/components/WorkflowDetail.tsx | 61 ++++++++++++++++++++++------ src/components/WorkflowNode.tsx | 2 +- src/routes/workflows/$workflowId.tsx | 27 +++++++++++- src/services/workflows.ts | 37 +++++++++++++++++ 4 files changed, 113 insertions(+), 14 deletions(-) diff --git a/src/components/WorkflowDetail.tsx b/src/components/WorkflowDetail.tsx index e720b4a..de5858a 100644 --- a/src/components/WorkflowDetail.tsx +++ b/src/components/WorkflowDetail.tsx @@ -1,4 +1,5 @@ -import { Button } from "@components/Button"; +import ButtonForGroup from "@components/ButtonForGroup"; +import { Dropdown, DropdownItem, DropdownMenu } from "@components/Dropdown"; import { Subheading } from "@components/Heading"; import JSONView from "@components/JSONView"; import RelativeTimeFormatter from "@components/RelativeTimeFormatter"; @@ -6,10 +7,15 @@ import { TaskStateIcon } from "@components/TaskStateIcon"; import TopNavTitleOnly from "@components/TopNavTitleOnly"; import WorkflowDiagram from "@components/WorkflowDiagram"; import { useFeatures } from "@contexts/Features.hook"; -import { EllipsisHorizontalIcon } from "@heroicons/react/20/solid"; +import { MenuButton as HeadlessMenuButton } from "@headlessui/react"; +import { + ArrowPathIcon, + ChevronDownIcon, + XCircleIcon, +} from "@heroicons/react/24/outline"; import { JobWithKnownMetadata } from "@services/jobs"; import { JobState } from "@services/types"; -import { Workflow } from "@services/workflows"; +import { Workflow, type WorkflowRetryMode } from "@services/workflows"; import { Link } from "@tanstack/react-router"; import { capitalize } from "@utils/string"; import clsx from "clsx"; @@ -25,6 +31,8 @@ type WorkflowDetailProps = { cancelPending?: boolean; loading: boolean; onCancel?: () => void; + onRetry?: (mode?: WorkflowRetryMode) => void; + retryPending?: boolean; selectedJobId: bigint | undefined; setSelectedJobId: (jobId: bigint | undefined) => void; workflow: undefined | Workflow; @@ -34,6 +42,8 @@ export default function WorkflowDetail({ cancelPending, loading, onCancel, + onRetry, + retryPending, selectedJobId, setSelectedJobId, workflow, @@ -111,18 +121,45 @@ export default function WorkflowDetail({

- {isActive && ( - - )} - + +
diff --git a/src/components/WorkflowNode.tsx b/src/components/WorkflowNode.tsx index f98d825..4fbf162 100644 --- a/src/components/WorkflowNode.tsx +++ b/src/components/WorkflowNode.tsx @@ -115,7 +115,7 @@ const JobDuration = ({ job }: { job: JobWithKnownMetadata }) => { case JobState.Running: return ; case JobState.Scheduled: - return ; + return ; } return "–"; diff --git a/src/routes/workflows/$workflowId.tsx b/src/routes/workflows/$workflowId.tsx index de414f9..b628ef2 100644 --- a/src/routes/workflows/$workflowId.tsx +++ b/src/routes/workflows/$workflowId.tsx @@ -1,7 +1,13 @@ import WorkflowDetail from "@components/WorkflowDetail"; import { useRefreshSetting } from "@contexts/RefreshSettings.hook"; import { toastSuccess } from "@services/toast"; -import { cancelJobs, getWorkflow, getWorkflowKey } from "@services/workflows"; +import { + cancelJobs, + getWorkflow, + getWorkflowKey, + retryWorkflow, + type WorkflowRetryMode, +} from "@services/workflows"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { createFileRoute, @@ -85,6 +91,20 @@ function WorkflowComponent() { }, }); + const retryMutation = useMutation({ + mutationFn: retryWorkflow, + onSuccess: () => { + if (workflowID) { + queryClient.invalidateQueries({ queryKey: getWorkflowKey(workflowID) }); + } + queryClient.invalidateQueries({ queryKey: ["listWorkflows"] }); + toastSuccess({ + message: "Workflow retry requested", + duration: 2000, + }); + }, + }); + return ( workflowID && cancelMutation.mutate({ workflowID: String(workflowID) }) } + onRetry={(mode?: WorkflowRetryMode) => + workflowID && + retryMutation.mutate({ workflowID: String(workflowID), mode }) + } + retryPending={retryMutation.isPending} selectedJobId={selectedJobId} setSelectedJobId={setSelectedJobId} workflow={workflow} diff --git a/src/services/workflows.ts b/src/services/workflows.ts index 1322742..1d415aa 100644 --- a/src/services/workflows.ts +++ b/src/services/workflows.ts @@ -21,6 +21,8 @@ export type Workflow = { tasks: JobWithKnownMetadata[]; }; +export type WorkflowRetryMode = "all" | "failed_and_downstream" | "failed_only"; + type CancelPayload = { workflowID: string; }; @@ -53,6 +55,41 @@ export const cancelJobs: MutationFunction< }; }; +type RetryWorkflowPayload = { + mode?: WorkflowRetryMode; + resetHistory?: boolean; + workflowID: string; +}; + +type RetryWorkflowResponse = { + retriedJobs: JobMinimal[]; +}; + +type RetryWorkflowResponseFromAPI = { + retried_jobs: JobMinimalFromAPI[]; +}; + +export const retryWorkflow: MutationFunction< + RetryWorkflowResponse, + RetryWorkflowPayload +> = async ({ mode, resetHistory, workflowID }) => { + const bodyObj: Record = {}; + if (mode) bodyObj.mode = mode; + bodyObj.reset_history = resetHistory ?? true; + + const response = await API.post( + `/pro/workflows/${workflowID}/retry`, + Object.keys(bodyObj).length ? JSON.stringify(bodyObj) : undefined, + Object.keys(bodyObj).length + ? { headers: { "Content-Type": "application/json" } } + : undefined, + ); + + return { + retriedJobs: response.retried_jobs.map(apiJobMinimalToJobMinimal), + }; +}; + type GetWorkflowKey = ["getWorkflow", string]; export const getWorkflowKey = (id: string): GetWorkflowKey => { From 32f86bd637e4e8603cd1c0ecf4c425d2290ae9c2 Mon Sep 17 00:00:00 2001 From: Blake Gentry Date: Sun, 14 Sep 2025 21:24:36 -0500 Subject: [PATCH 4/6] JobTImeline: fix wait duration --- src/components/JobTimeline.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/components/JobTimeline.tsx b/src/components/JobTimeline.tsx index 73c5d15..41e4a7e 100644 --- a/src/components/JobTimeline.tsx +++ b/src/components/JobTimeline.tsx @@ -189,7 +189,8 @@ const WaitStep = ({ job }: { job: Job }) => { return ( - () + ( + ) ); }; From 82a2b06f23bb5c78460ff83b2951538158b8f2c0 Mon Sep 17 00:00:00 2001 From: Blake Gentry Date: Sun, 14 Sep 2025 21:25:49 -0500 Subject: [PATCH 5/6] changelog --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f4cdb1..53e306b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,11 +10,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Added the ability to cancel workflows from the workflow detail page. [PR #407](https://github.com/riverqueue/riverui/pull/407). +- Added the ability to retry workflows from the workflow detail page. [PR #430](https://github.com/riverqueue/riverui/pull/430). ### Fixed - Workflow detail: prevent the viewport from getting stuck zoomed into empty space after navigating between the workflow list and detail pages by remounting the diagram per workflow and enabling fit-to-view on mount. [PR #408](https://github.com/riverqueue/riverui/pull/408) - Workflow detail: make MiniMap render nodes correctly and respect dark mode by setting explicit node bounds and theme-aware colors. [PR #408](https://github.com/riverqueue/riverui/pull/408) +- Job detail: show correct wait duration for running or completed jobs. [PR #430](https://github.com/riverqueue/riverui/pull/430). ## [v0.12.2] - 2025-08-16 From 3c0e12dd2445194576119ad44f026951dd11359a Mon Sep 17 00:00:00 2001 From: Blake Gentry Date: Thu, 18 Sep 2025 09:47:30 -0500 Subject: [PATCH 6/6] workflow retry: modal dialog, reset history option --- src/components/RetryWorkflowDialog.test.tsx | 197 +++++++++++++++++ src/components/RetryWorkflowDialog.tsx | 223 ++++++++++++++++++++ src/components/WorkflowDetail.tsx | 66 +++--- src/routes/workflows/$workflowId.tsx | 8 +- src/services/workflows.ts | 2 +- 5 files changed, 455 insertions(+), 41 deletions(-) create mode 100644 src/components/RetryWorkflowDialog.test.tsx create mode 100644 src/components/RetryWorkflowDialog.tsx diff --git a/src/components/RetryWorkflowDialog.test.tsx b/src/components/RetryWorkflowDialog.test.tsx new file mode 100644 index 0000000..21a6b62 --- /dev/null +++ b/src/components/RetryWorkflowDialog.test.tsx @@ -0,0 +1,197 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +import RetryWorkflowDialog from "./RetryWorkflowDialog"; + +describe("RetryWorkflowDialog", () => { + it("renders title and disables confirm until a mode is selected", async () => { + const onClose = vi.fn(); + const onConfirm = vi.fn(); + render( + , + ); + + await waitFor(() => { + expect(screen.getByText("Retry workflow")).toBeInTheDocument(); + const confirm = screen.getByRole("button", { name: /re-run jobs/i }); + expect(confirm).toBeDisabled(); + }); + }); + + it("selects a mode and confirms with reset history off by default", async () => { + const onClose = vi.fn(); + const onConfirm = vi.fn(); + render( + , + ); + + fireEvent.click(screen.getByLabelText(/All jobs/i)); + const confirm = await waitFor(() => { + const c = screen.getByRole("button", { name: /re-run jobs/i }); + expect(c).not.toBeDisabled(); + return c; + }); + fireEvent.click(confirm); + expect(onConfirm).toHaveBeenCalledWith("all", false); + }); + + it("passes reset history true when checked", async () => { + const onClose = vi.fn(); + const onConfirm = vi.fn(); + render( + , + ); + + fireEvent.click(screen.getByLabelText(/Only failed jobs/i)); + fireEvent.click(screen.getByLabelText(/Reset history/i)); + fireEvent.click(screen.getByRole("button", { name: /re-run jobs/i })); + await waitFor(() => { + expect(onConfirm).toHaveBeenCalledWith("failed_only", true); + }); + }); + + it("initializes with default mode selected and confirm enabled", async () => { + const onClose = vi.fn(); + const onConfirm = vi.fn(); + render( + , + ); + + await waitFor(() => { + const radio = screen.getByLabelText(/All jobs/i); + expect(radio).toBeChecked(); + + const confirm = screen.getByRole("button", { name: /re-run jobs/i }); + expect(confirm).not.toBeDisabled(); + }); + }); + + it("initializes with default reset history checked", async () => { + const onClose = vi.fn(); + const onConfirm = vi.fn(); + render( + , + ); + + await waitFor(() => { + const checkbox = screen.getByLabelText(/Reset history/i); + expect(checkbox).toBeChecked(); + }); + }); + + it("disables confirm when pending even if mode selected", async () => { + const onClose = vi.fn(); + const onConfirm = vi.fn(); + render( + , + ); + + await waitFor(() => { + const confirm = screen.getByRole("button", { name: /re-run jobs/i }); + expect(confirm).toBeDisabled(); + }); + }); + + it("calls onClose when cancel button is clicked", async () => { + const onClose = vi.fn(); + const onConfirm = vi.fn(); + render( + , + ); + + fireEvent.click(screen.getByRole("button", { name: /Cancel/i })); + await waitFor(() => { + expect(onClose).toHaveBeenCalled(); + }); + }); + + it("renders nothing when not open", () => { + const onClose = vi.fn(); + const onConfirm = vi.fn(); + const { container } = render( + , + ); + + expect(container).toBeEmptyDOMElement(); + }); + + it("resets state when dialog closes and reopens", async () => { + const onClose = vi.fn(); + const onConfirm = vi.fn(); + const { rerender } = render( + , + ); + + // Change state + fireEvent.click(screen.getByLabelText(/Only failed jobs/i)); + fireEvent.click(screen.getByLabelText(/Reset history/i)); + + // Close + rerender( + , + ); + + // Reopen + rerender( + , + ); + + await waitFor(() => { + expect(screen.getByLabelText(/All jobs/i)).toBeChecked(); + expect(screen.getByLabelText(/Only failed jobs/i)).not.toBeChecked(); + expect(screen.getByLabelText(/Reset history/i)).not.toBeChecked(); + }); + }); + + it("selects failed and downstream mode and confirms", async () => { + const onClose = vi.fn(); + const onConfirm = vi.fn(); + render( + , + ); + + fireEvent.click(screen.getByLabelText(/Failed jobs \+ dependents/i)); + fireEvent.click(screen.getByRole("button", { name: /re-run jobs/i })); + await waitFor(() => { + expect(onConfirm).toHaveBeenCalledWith("failed_and_downstream", false); + }); + }); +}); diff --git a/src/components/RetryWorkflowDialog.tsx b/src/components/RetryWorkflowDialog.tsx new file mode 100644 index 0000000..039c1a6 --- /dev/null +++ b/src/components/RetryWorkflowDialog.tsx @@ -0,0 +1,223 @@ +import { + Dialog, + DialogBackdrop, + DialogPanel, + DialogTitle, +} from "@headlessui/react"; +import { type WorkflowRetryMode } from "@services/workflows"; +import { useEffect, useState } from "react"; + +export type RetryWorkflowDialogProps = { + defaultMode?: WorkflowRetryMode; + defaultResetHistory?: boolean; + onClose: () => void; + onConfirm: (mode: WorkflowRetryMode, resetHistory: boolean) => void; + open: boolean; + pending?: boolean; +}; + +export default function RetryWorkflowDialog({ + defaultMode, + defaultResetHistory = false, + onClose, + onConfirm, + open, + pending, +}: RetryWorkflowDialogProps) { + const [mode, setMode] = useState(defaultMode); + const [resetHistory, setResetHistory] = + useState(defaultResetHistory); + + useEffect(() => { + if (!open) { + setMode(defaultMode); + setResetHistory(defaultResetHistory); + } + }, [open, defaultMode, defaultResetHistory]); + + return ( + + + +
+
+ +
+
+
+
+ + Retry workflow + +
+ Choose how to retry this workflow. +
+ +
+
+
+ + + + + +
+
+ +
+

+ Options +

+ +
+
+
+
+
+
+ + +
+
+
+
+
+
+ ); +} diff --git a/src/components/WorkflowDetail.tsx b/src/components/WorkflowDetail.tsx index de5858a..5ff5cfb 100644 --- a/src/components/WorkflowDetail.tsx +++ b/src/components/WorkflowDetail.tsx @@ -1,25 +1,21 @@ import ButtonForGroup from "@components/ButtonForGroup"; -import { Dropdown, DropdownItem, DropdownMenu } from "@components/Dropdown"; import { Subheading } from "@components/Heading"; import JSONView from "@components/JSONView"; import RelativeTimeFormatter from "@components/RelativeTimeFormatter"; +import RetryWorkflowDialog from "@components/RetryWorkflowDialog"; import { TaskStateIcon } from "@components/TaskStateIcon"; import TopNavTitleOnly from "@components/TopNavTitleOnly"; import WorkflowDiagram from "@components/WorkflowDiagram"; import { useFeatures } from "@contexts/Features.hook"; -import { MenuButton as HeadlessMenuButton } from "@headlessui/react"; -import { - ArrowPathIcon, - ChevronDownIcon, - XCircleIcon, -} from "@heroicons/react/24/outline"; +// (Dialog is now encapsulated in RetryWorkflowDialog) +import { ArrowPathIcon, XCircleIcon } from "@heroicons/react/24/outline"; import { JobWithKnownMetadata } from "@services/jobs"; import { JobState } from "@services/types"; import { Workflow, type WorkflowRetryMode } from "@services/workflows"; import { Link } from "@tanstack/react-router"; import { capitalize } from "@utils/string"; import clsx from "clsx"; -import { useMemo } from "react"; +import { useMemo, useState } from "react"; import WorkflowListEmptyState from "./WorkflowListEmptyState"; @@ -31,7 +27,7 @@ type WorkflowDetailProps = { cancelPending?: boolean; loading: boolean; onCancel?: () => void; - onRetry?: (mode?: WorkflowRetryMode) => void; + onRetry?: (mode: WorkflowRetryMode, resetHistory: boolean) => void; retryPending?: boolean; selectedJobId: bigint | undefined; setSelectedJobId: (jobId: bigint | undefined) => void; @@ -78,6 +74,10 @@ export default function WorkflowDetail({ return Boolean(workflow?.tasks?.some((t) => activeStates.has(t.state))); }, [workflow?.tasks]); + // Modal state for retry + const [retryOpen, setRetryOpen] = useState(false); + const [retryMode, setRetryMode] = useState(); + if (!features.workflowQueries) { return (
@@ -122,35 +122,13 @@ export default function WorkflowDetail({
- - - - - onRetry?.("all")}> - All jobs - - onRetry?.("failed_only")}> - Only failed jobs - - onRetry?.("failed_and_downstream")} - > - Failed jobs + dependents - - - + setRetryOpen(true)} + > + )}
+ + setRetryOpen(false)} + onConfirm={(mode, reset) => { + onRetry?.(mode, reset); + setRetryOpen(false); + setRetryMode(undefined); + }} + open={retryOpen} + pending={retryPending} + /> ); } diff --git a/src/routes/workflows/$workflowId.tsx b/src/routes/workflows/$workflowId.tsx index b628ef2..9356304 100644 --- a/src/routes/workflows/$workflowId.tsx +++ b/src/routes/workflows/$workflowId.tsx @@ -112,9 +112,13 @@ function WorkflowComponent() { onCancel={() => workflowID && cancelMutation.mutate({ workflowID: String(workflowID) }) } - onRetry={(mode?: WorkflowRetryMode) => + onRetry={(mode: WorkflowRetryMode, resetHistory: boolean) => workflowID && - retryMutation.mutate({ workflowID: String(workflowID), mode }) + retryMutation.mutate({ + workflowID: String(workflowID), + mode, + resetHistory, + }) } retryPending={retryMutation.isPending} selectedJobId={selectedJobId} diff --git a/src/services/workflows.ts b/src/services/workflows.ts index 1d415aa..094d3a0 100644 --- a/src/services/workflows.ts +++ b/src/services/workflows.ts @@ -75,7 +75,7 @@ export const retryWorkflow: MutationFunction< > = async ({ mode, resetHistory, workflowID }) => { const bodyObj: Record = {}; if (mode) bodyObj.mode = mode; - bodyObj.reset_history = resetHistory ?? true; + if (typeof resetHistory === "boolean") bodyObj.reset_history = resetHistory; const response = await API.post( `/pro/workflows/${workflowID}/retry`,