From 6ddc4323a7f247293aec8168d3cb97ef7092774e Mon Sep 17 00:00:00 2001 From: Lanre Adelowo Date: Wed, 20 May 2026 22:51:07 +0100 Subject: [PATCH 1/3] feature gate job deletions --- handler.go | 12 +++++- handler_api_endpoint.go | 10 +++-- handler_test.go | 11 +++--- internal/apibundle/api_bundle.go | 1 + internal/riveruicmd/riveruicmd.go | 2 + src/App.tsx | 1 + src/components/JobDetail.stories.ts | 6 +++ src/components/JobDetail.test.tsx | 2 + src/components/JobDetail.tsx | 55 ++++++++++++++++---------- src/components/JobList.test.tsx | 1 + src/components/JobList.tsx | 54 ++++++++++++++----------- src/components/WorkflowDetail.test.tsx | 1 + src/routes/jobs/$jobId.tsx | 3 +- src/services/features.test.ts | 4 ++ src/services/features.ts | 2 + src/test/utils/features.ts | 1 + uiendpoints/bundle.go | 1 + 17 files changed, 112 insertions(+), 55 deletions(-) diff --git a/handler.go b/handler.go index 835a38fb..b3ee687c 100644 --- a/handler.go +++ b/handler.go @@ -97,16 +97,16 @@ func (e *endpoints[TTx]) MountEndpoints(archetype *baseservice.Archetype, logger DB: executor, Driver: driver, Extensions: e.Extensions, + JobDeletionEnabled: e.bundleOpts.JobDeletionEnabled, JobListHideArgsByDefault: e.bundleOpts.JobListHideArgsByDefault, Logger: logger, } - return []apiendpoint.EndpointInterface{ + endpoints := []apiendpoint.EndpointInterface{ apiendpoint.Mount(mux, newAutocompleteListEndpoint(bundle), mountOpts), apiendpoint.Mount(mux, newFeaturesGetEndpoint(bundle), mountOpts), apiendpoint.Mount(mux, newHealthCheckGetEndpoint(bundle), mountOpts), apiendpoint.Mount(mux, newJobCancelEndpoint(bundle), mountOpts), - apiendpoint.Mount(mux, newJobDeleteEndpoint(bundle), mountOpts), apiendpoint.Mount(mux, newJobGetEndpoint(bundle), mountOpts), apiendpoint.Mount(mux, newJobListEndpoint(bundle), mountOpts), apiendpoint.Mount(mux, newJobRetryEndpoint(bundle), mountOpts), @@ -117,6 +117,12 @@ func (e *endpoints[TTx]) MountEndpoints(archetype *baseservice.Archetype, logger apiendpoint.Mount(mux, newQueueUpdateEndpoint(bundle), mountOpts), apiendpoint.Mount(mux, newStateAndCountGetEndpoint(bundle), mountOpts), } + + if e.bundleOpts.JobDeletionEnabled { + endpoints = append(endpoints, apiendpoint.Mount(mux, newJobDeleteEndpoint(bundle), mountOpts)) + } + + return endpoints } // HandlerOpts are the options for creating a new Handler. @@ -124,6 +130,7 @@ type HandlerOpts struct { // DevMode is whether the server is running in development mode. DevMode bool Endpoints uiendpoints.Bundle + JobDeletionEnabled bool JobListHideArgsByDefault bool // LiveFS is whether to use the live filesystem for the frontend. LiveFS bool @@ -186,6 +193,7 @@ func NewHandler(opts *HandlerOpts) (*Handler, error) { } opts.Endpoints.Configure(&uiendpoints.BundleOpts{ + JobDeletionEnabled: opts.JobDeletionEnabled, JobListHideArgsByDefault: opts.JobListHideArgsByDefault, }) diff --git a/handler_api_endpoint.go b/handler_api_endpoint.go index 22cd326f..70f8b100 100644 --- a/handler_api_endpoint.go +++ b/handler_api_endpoint.go @@ -175,8 +175,9 @@ func (*featuresGetEndpoint[TTx]) Meta() *apiendpoint.EndpointMeta { type featuresGetRequest struct{} type featuresGetResponse struct { - Extensions map[string]bool `json:"extensions"` - JobListHideArgsByDefault bool `json:"job_list_hide_args_by_default"` + FeatureJobDeletionEnabled bool `json:"feature_job_deletion_enabled"` + Extensions map[string]bool `json:"extensions"` + JobListHideArgsByDefault bool `json:"job_list_hide_args_by_default"` } func (a *featuresGetEndpoint[TTx]) Execute(ctx context.Context, _ *featuresGetRequest) (*featuresGetResponse, error) { @@ -186,8 +187,9 @@ func (a *featuresGetEndpoint[TTx]) Execute(ctx context.Context, _ *featuresGetRe } return &featuresGetResponse{ - Extensions: extensions, - JobListHideArgsByDefault: a.JobListHideArgsByDefault, + FeatureJobDeletionEnabled: a.JobDeletionEnabled, + Extensions: extensions, + JobListHideArgsByDefault: a.JobListHideArgsByDefault, }, nil } diff --git a/handler_test.go b/handler_test.go index 7a52ad6a..7e239270 100644 --- a/handler_test.go +++ b/handler_test.go @@ -52,11 +52,12 @@ func TestNewHandlerIntegration(t *testing.T) { logger := riversharedtest.Logger(t) server, err := NewHandler(&HandlerOpts{ - DevMode: true, - Endpoints: bundle, - LiveFS: true, - Logger: logger, - projectRoot: "./", + DevMode: true, + Endpoints: bundle, + JobDeletionEnabled: true, + LiveFS: true, + Logger: logger, + projectRoot: "./", }) require.NoError(t, err) return server diff --git a/internal/apibundle/api_bundle.go b/internal/apibundle/api_bundle.go index fda6db52..7480f0fe 100644 --- a/internal/apibundle/api_bundle.go +++ b/internal/apibundle/api_bundle.go @@ -16,6 +16,7 @@ type APIBundle[TTx any] struct { DB riverdriver.Executor Driver riverdriver.Driver[TTx] Extensions func(ctx context.Context) (map[string]bool, error) + JobDeletionEnabled bool JobListHideArgsByDefault bool Logger *slog.Logger } diff --git a/internal/riveruicmd/riveruicmd.go b/internal/riveruicmd/riveruicmd.go index 606e439b..5a779fd7 100644 --- a/internal/riveruicmd/riveruicmd.go +++ b/internal/riveruicmd/riveruicmd.go @@ -163,6 +163,7 @@ func initServer[TClient any](ctx context.Context, opts *initServerOpts, createCl corsOrigins = strings.Split(os.Getenv("CORS_ORIGINS"), ",") databaseURL = os.Getenv("DATABASE_URL") devMode = envBooleanTrue(os.Getenv("DEV")) + jobDeletionEnabled = envBooleanTrue(os.Getenv("FEATURE_JOB_DELETION_ENABLED")) jobListHideArgsByDefault = envBooleanTrue(os.Getenv("RIVER_JOB_LIST_HIDE_ARGS_BY_DEFAULT")) host = os.Getenv("RIVER_HOST") // may be left empty to bind to all local interfaces liveFS = envBooleanTrue(os.Getenv("LIVE_FS")) @@ -192,6 +193,7 @@ func initServer[TClient any](ctx context.Context, opts *initServerOpts, createCl uiHandler, err := riverui.NewHandler(&riverui.HandlerOpts{ DevMode: devMode, Endpoints: createBundle(client), + JobDeletionEnabled: jobDeletionEnabled, JobListHideArgsByDefault: jobListHideArgsByDefault, LiveFS: liveFS, Logger: opts.logger, diff --git a/src/App.tsx b/src/App.tsx index 48535d3a..ab2c5e04 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -29,6 +29,7 @@ const router = createRouter({ basepath: getBasePath(), context: { features: { + featureJobDeletionEnabled: false, hasClientTable: false, hasProducerTable: false, producerQueries: false, diff --git a/src/components/JobDetail.stories.ts b/src/components/JobDetail.stories.ts index c14e5677..70f83322 100644 --- a/src/components/JobDetail.stories.ts +++ b/src/components/JobDetail.stories.ts @@ -5,6 +5,12 @@ import { jobFactory } from "@test/factories/job"; import JobDetail from "./JobDetail"; const meta: Meta = { + args: { + cancel: () => {}, + deleteFn: () => {}, + jobDeletionEnabled: true, + retry: () => {}, + }, component: JobDetail, title: "Pages/JobDetail", }; diff --git a/src/components/JobDetail.test.tsx b/src/components/JobDetail.test.tsx index 9e1aa95b..ab88742c 100644 --- a/src/components/JobDetail.test.tsx +++ b/src/components/JobDetail.test.tsx @@ -14,6 +14,7 @@ test("requires confirmation before deleting a job", async () => { , @@ -53,6 +54,7 @@ test("cancels job delete confirmation", async () => { , diff --git a/src/components/JobDetail.tsx b/src/components/JobDetail.tsx index 693abc4f..2ca264da 100644 --- a/src/components/JobDetail.tsx +++ b/src/components/JobDetail.tsx @@ -21,6 +21,7 @@ import { FormEvent, useState } from "react"; type JobDetailProps = { cancel: () => void; deleteFn: () => void; + jobDeletionEnabled: boolean; job: Job; retry: () => void; }; @@ -28,6 +29,7 @@ type JobDetailProps = { export default function JobDetail({ cancel, deleteFn, + jobDeletionEnabled, job, retry, }: JobDetailProps) { @@ -59,6 +61,7 @@ export default function JobDetail({ @@ -185,7 +188,13 @@ export default function JobDetail({ ); } -function ActionButtons({ cancel, deleteFn, job, retry }: JobDetailProps) { +function ActionButtons({ + cancel, + deleteFn, + jobDeletionEnabled, + job, + retry, +}: JobDetailProps) { const [deleteConfirmationOpen, setDeleteConfirmationOpen] = useState(false); // Can only delete jobs that aren't running: @@ -237,27 +246,31 @@ function ActionButtons({ cancel, deleteFn, job, retry }: JobDetailProps) { onClick={cancelJob} text="Cancel" /> - + {jobDeletionEnabled && ( + + )} - - This permanently deletes job{" "} - {job.id.toString()}. This action - cannot be undone. - - } - onClose={() => setDeleteConfirmationOpen(false)} - onConfirm={confirmDelete} - open={deleteConfirmationOpen} - title="Delete job?" - /> + {jobDeletionEnabled && ( + + This permanently deletes job{" "} + {job.id.toString()}. This + action cannot be undone. + + } + onClose={() => setDeleteConfirmationOpen(false)} + onConfirm={confirmDelete} + open={deleteConfirmationOpen} + title="Delete job?" + /> + )} ); } diff --git a/src/components/JobList.test.tsx b/src/components/JobList.test.tsx index d8e202ce..44db5ba4 100644 --- a/src/components/JobList.test.tsx +++ b/src/components/JobList.test.tsx @@ -194,6 +194,7 @@ describe("JobList", () => { const deleteJobs = vi.fn(); const user = userEvent.setup(); const features = createFeatures({ + featureJobDeletionEnabled: true, jobListHideArgsByDefault: false, }); diff --git a/src/components/JobList.tsx b/src/components/JobList.tsx index 820004fd..f1c96a8d 100644 --- a/src/components/JobList.tsx +++ b/src/components/JobList.tsx @@ -182,6 +182,7 @@ export type JobRowsProps = { deleteJobs: (jobIDs: bigint[]) => void; hideArgs: boolean; initialFilters?: Filter[]; + jobDeletionEnabled: boolean; jobs: JobMinimal[]; onFiltersChange?: (filters: Filter[]) => void; retryJobs: (jobIDs: bigint[]) => void; @@ -212,6 +213,7 @@ function JobListActionButtons({ cancel, className, deleteFn, + jobDeletionEnabled, jobIDs, retry, state, @@ -219,6 +221,7 @@ function JobListActionButtons({ cancel: (jobIDs: bigint[]) => void; className?: string; deleteFn: (jobIDs: bigint[]) => void; + jobDeletionEnabled: boolean; jobIDs: bigint[]; retry: (jobIDs: bigint[]) => void; state: JobState; @@ -279,29 +282,33 @@ function JobListActionButtons({ onClick={cancelJob} text="Cancel" /> - + {jobDeletionEnabled && ( + + )} - setDeleteConfirmationOpen(false)} - onConfirm={confirmDelete} - open={deleteConfirmationOpen} - title={ - selectedJobCount === 1 - ? "Delete selected job?" - : "Delete selected jobs?" - } - /> + {jobDeletionEnabled && ( + setDeleteConfirmationOpen(false)} + onConfirm={confirmDelete} + open={deleteConfirmationOpen} + title={ + selectedJobCount === 1 + ? "Delete selected job?" + : "Delete selected jobs?" + } + /> + )} ); } @@ -313,6 +320,7 @@ const JobRows = ({ deleteJobs, hideArgs, initialFilters, + jobDeletionEnabled, jobs, onFiltersChange, retryJobs, @@ -382,6 +390,7 @@ const JobRows = ({ { deleteJobs={deleteJobs} hideArgs={hideArgs} initialFilters={initialFilters} + jobDeletionEnabled={features.featureJobDeletionEnabled} jobs={jobs} onFiltersChange={onFiltersChange} retryJobs={retryJobs} diff --git a/src/components/WorkflowDetail.test.tsx b/src/components/WorkflowDetail.test.tsx index 444cbc83..45c0dcde 100644 --- a/src/components/WorkflowDetail.test.tsx +++ b/src/components/WorkflowDetail.test.tsx @@ -42,6 +42,7 @@ vi.mock("@components/workflow-diagram/WorkflowDiagram", () => ({ const features: Features = { durablePeriodicJobs: false, + featureJobDeletionEnabled: false, hasClientTable: false, hasProducerTable: true, hasSequenceTable: false, diff --git a/src/routes/jobs/$jobId.tsx b/src/routes/jobs/$jobId.tsx index d7b48377..43a857fd 100644 --- a/src/routes/jobs/$jobId.tsx +++ b/src/routes/jobs/$jobId.tsx @@ -55,7 +55,7 @@ export const Route = createFileRoute("/jobs/$jobId")({ function JobComponent() { const { jobId } = Route.useParams(); const navigate = Route.useNavigate(); - const { queryOptions } = Route.useRouteContext(); + const { features, queryOptions } = Route.useRouteContext(); const refreshSettings = useRefreshSetting(); const queryOptionsWithRefresh = useMemo( () => ({ ...queryOptions, refetchInterval: refreshSettings.intervalMs }), @@ -122,6 +122,7 @@ function JobComponent() { diff --git a/src/services/features.test.ts b/src/services/features.test.ts index 31d9cc62..388199c5 100644 --- a/src/services/features.test.ts +++ b/src/services/features.test.ts @@ -12,11 +12,13 @@ describe("apiFeaturesToFeatures", () => { producer_queries: true, workflow_queries: true, }, + feature_job_deletion_enabled: true, job_list_hide_args_by_default: true, } as const; const expected = { durablePeriodicJobs: true, + featureJobDeletionEnabled: true, hasClientTable: true, hasProducerTable: true, hasSequenceTable: false, @@ -37,11 +39,13 @@ describe("apiFeaturesToFeatures", () => { producer_queries: false, workflow_queries: false, }, + feature_job_deletion_enabled: false, job_list_hide_args_by_default: false, } as const; const expected = { durablePeriodicJobs: false, + featureJobDeletionEnabled: false, hasClientTable: false, hasProducerTable: false, hasSequenceTable: false, diff --git a/src/services/features.ts b/src/services/features.ts index a3d876db..1585d6f1 100644 --- a/src/services/features.ts +++ b/src/services/features.ts @@ -13,6 +13,7 @@ export type Features = { type FeaturesFromAPI = { extensions: Record; + feature_job_deletion_enabled: boolean; job_list_hide_args_by_default: boolean; }; @@ -67,6 +68,7 @@ export const apiFeaturesToFeatures = (features: FeaturesFromAPI): Features => { } return { + featureJobDeletionEnabled: features.feature_job_deletion_enabled, jobListHideArgsByDefault: features.job_list_hide_args_by_default, ...completeKnownExtensions, }; diff --git a/src/test/utils/features.ts b/src/test/utils/features.ts index d7f73952..421e745f 100644 --- a/src/test/utils/features.ts +++ b/src/test/utils/features.ts @@ -4,6 +4,7 @@ export const createFeatures = ( overrides: Partial = {}, ): Features => ({ durablePeriodicJobs: false, + featureJobDeletionEnabled: false, hasClientTable: false, hasProducerTable: false, hasSequenceTable: false, diff --git a/uiendpoints/bundle.go b/uiendpoints/bundle.go index 056db0c5..99f86b9f 100644 --- a/uiendpoints/bundle.go +++ b/uiendpoints/bundle.go @@ -9,6 +9,7 @@ import ( ) type BundleOpts struct { + JobDeletionEnabled bool JobListHideArgsByDefault bool } From f5b6f106f011d72d202fac7249e2fc2fc56d2b38 Mon Sep 17 00:00:00 2001 From: Lanre Adelowo Date: Wed, 20 May 2026 22:53:46 +0100 Subject: [PATCH 2/3] add env value --- .env.local | 1 + 1 file changed, 1 insertion(+) diff --git a/.env.local b/.env.local index b4640f0a..40d0e025 100644 --- a/.env.local +++ b/.env.local @@ -4,3 +4,4 @@ TEST_DATABASE_URL=postgres://postgres:postgres@localhost:5432/river_test OTEL_ENABLED=false PORT=8080 VITE_RIVER_API_BASE_URL=http://localhost:8080/api +FEATURE_JOB_DELETION_ENABLED=false From d36f7b44624584e6ffc81fe0b71ad4a66949f9e9 Mon Sep 17 00:00:00 2001 From: Lanre Adelowo Date: Wed, 20 May 2026 23:16:39 +0100 Subject: [PATCH 3/3] keep existing behavior by making deletions available unless toggled --- .env.local | 2 +- internal/riveruicmd/riveruicmd.go | 2 +- src/App.tsx | 2 +- src/test/utils/features.ts | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.env.local b/.env.local index 40d0e025..522e40d4 100644 --- a/.env.local +++ b/.env.local @@ -4,4 +4,4 @@ TEST_DATABASE_URL=postgres://postgres:postgres@localhost:5432/river_test OTEL_ENABLED=false PORT=8080 VITE_RIVER_API_BASE_URL=http://localhost:8080/api -FEATURE_JOB_DELETION_ENABLED=false +FEATURE_JOB_DELETION_DISABLED=false diff --git a/internal/riveruicmd/riveruicmd.go b/internal/riveruicmd/riveruicmd.go index 5a779fd7..18f80416 100644 --- a/internal/riveruicmd/riveruicmd.go +++ b/internal/riveruicmd/riveruicmd.go @@ -163,7 +163,7 @@ func initServer[TClient any](ctx context.Context, opts *initServerOpts, createCl corsOrigins = strings.Split(os.Getenv("CORS_ORIGINS"), ",") databaseURL = os.Getenv("DATABASE_URL") devMode = envBooleanTrue(os.Getenv("DEV")) - jobDeletionEnabled = envBooleanTrue(os.Getenv("FEATURE_JOB_DELETION_ENABLED")) + jobDeletionEnabled = !envBooleanTrue(os.Getenv("FEATURE_JOB_DELETION_DISABLED")) jobListHideArgsByDefault = envBooleanTrue(os.Getenv("RIVER_JOB_LIST_HIDE_ARGS_BY_DEFAULT")) host = os.Getenv("RIVER_HOST") // may be left empty to bind to all local interfaces liveFS = envBooleanTrue(os.Getenv("LIVE_FS")) diff --git a/src/App.tsx b/src/App.tsx index ab2c5e04..eca3b2c8 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -29,7 +29,7 @@ const router = createRouter({ basepath: getBasePath(), context: { features: { - featureJobDeletionEnabled: false, + featureJobDeletionEnabled: true, hasClientTable: false, hasProducerTable: false, producerQueries: false, diff --git a/src/test/utils/features.ts b/src/test/utils/features.ts index 421e745f..9f669113 100644 --- a/src/test/utils/features.ts +++ b/src/test/utils/features.ts @@ -4,7 +4,7 @@ export const createFeatures = ( overrides: Partial = {}, ): Features => ({ durablePeriodicJobs: false, - featureJobDeletionEnabled: false, + featureJobDeletionEnabled: true, hasClientTable: false, hasProducerTable: false, hasSequenceTable: false,