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 (
+
+ );
+}
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({
-
-
-
- Retry
-
-
-
- onRetry?.("all")}>
- All jobs
-
- onRetry?.("failed_only")}>
- Only failed jobs
-
- onRetry?.("failed_and_downstream")}
- >
- Failed jobs + dependents
-
-
-
+ setRetryOpen(true)}
+ >
+
+ Retry
+
)}
+
+ 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`,