11import { parse } from "@conform-to/zod" ;
2- import { type ActionFunction , json } from "@remix-run/node" ;
2+ import { json } from "@remix-run/node" ;
33import { z } from "zod" ;
4- import { prisma } from "~/db.server" ;
4+ import { $replica , prisma } from "~/db.server" ;
55import { redirectWithErrorMessage , redirectWithSuccessMessage } from "~/models/message.server" ;
66import { logger } from "~/services/logger.server" ;
7- import { requireUserId } from "~/services/session.server " ;
7+ import { dashboardAction } from "~/services/routeBuilders/dashboardBuilder " ;
88import { CancelTaskRunService } from "~/v3/services/cancelTaskRun.server" ;
99import { getMollifierBuffer } from "~/v3/mollifier/mollifierBuffer.server" ;
1010
@@ -16,104 +16,130 @@ const ParamSchema = z.object({
1616 runParam : z . string ( ) ,
1717} ) ;
1818
19- export const action : ActionFunction = async ( { request, params } ) => {
20- const userId = await requireUserId ( request ) ;
21- const { runParam } = ParamSchema . parse ( params ) ;
19+ // Resolve the run's organization so the RBAC auth scope can resolve the
20+ // user's role in it. The run may not be in Postgres yet (buffered during a
21+ // burst), so fall back to the buffer entry's org.
22+ async function resolveRunOrganizationId ( runParam : string ) : Promise < string | null > {
23+ const run = await $replica . taskRun . findFirst ( {
24+ where : { friendlyId : runParam } ,
25+ select : { project : { select : { organizationId : true } } } ,
26+ } ) ;
27+ if ( run ) {
28+ return run . project . organizationId ;
29+ }
2230
23- const formData = await request . formData ( ) ;
24- const submission = parse ( formData , { schema : cancelSchema } ) ;
31+ const buffer = getMollifierBuffer ( ) ;
32+ const entry = buffer ? await buffer . getEntry ( runParam ) : null ;
33+ return entry ?. orgId ?? null ;
34+ }
2535
26- if ( ! submission . value ) {
27- return json ( submission ) ;
28- }
36+ export const action = dashboardAction (
37+ {
38+ params : ParamSchema ,
39+ context : async ( params ) => {
40+ const organizationId = await resolveRunOrganizationId ( params . runParam ) ;
41+ return organizationId ? { organizationId } : { } ;
42+ } ,
43+ authorization : { action : "write" , resource : { type : "runs" } } ,
44+ } ,
45+ async ( { request, params, user } ) => {
46+ const { runParam } = params ;
47+
48+ const formData = await request . formData ( ) ;
49+ const submission = parse ( formData , { schema : cancelSchema } ) ;
50+
51+ if ( ! submission . value ) {
52+ return json ( submission ) ;
53+ }
2954
30- try {
31- const taskRun = await prisma . taskRun . findFirst ( {
32- where : {
33- friendlyId : runParam ,
34- project : {
35- organization : {
36- members : {
37- some : {
38- userId,
55+ try {
56+ const taskRun = await prisma . taskRun . findFirst ( {
57+ where : {
58+ friendlyId : runParam ,
59+ project : {
60+ organization : {
61+ members : {
62+ some : {
63+ userId : user . id ,
64+ } ,
3965 } ,
4066 } ,
4167 } ,
4268 } ,
43- } ,
44- } ) ;
69+ } ) ;
4570
46- if ( taskRun ) {
47- const cancelRunService = new CancelTaskRunService ( ) ;
48- await cancelRunService . call ( taskRun ) ;
49- return redirectWithSuccessMessage ( submission . value . redirectUrl , request , `Canceled run` ) ;
50- }
71+ if ( taskRun ) {
72+ const cancelRunService = new CancelTaskRunService ( ) ;
73+ await cancelRunService . call ( taskRun ) ;
74+ return redirectWithSuccessMessage ( submission . value . redirectUrl , request , `Canceled run` ) ;
75+ }
5176
52- // PG miss — try the mollifier buffer. The customer can hit cancel
53- // on a buffered run from the dashboard during the burst window.
54- // Snapshot a `mark_cancelled` patch; the drainer's
55- // bifurcation routes the run to `engine.createCancelledRun` on
56- // next pop.
57- const buffer = getMollifierBuffer ( ) ;
58- const entry = buffer ? await buffer . getEntry ( runParam ) : null ;
59- if ( ! entry ) {
60- submission . error = { runParam : [ "Run not found" ] } ;
61- return json ( submission ) ;
62- }
77+ // PG miss — try the mollifier buffer. The customer can hit cancel
78+ // on a buffered run from the dashboard during the burst window.
79+ // Snapshot a `mark_cancelled` patch; the drainer's
80+ // bifurcation routes the run to `engine.createCancelledRun` on
81+ // next pop.
82+ const buffer = getMollifierBuffer ( ) ;
83+ const entry = buffer ? await buffer . getEntry ( runParam ) : null ;
84+ if ( ! entry ) {
85+ submission . error = { runParam : [ "Run not found" ] } ;
86+ return json ( submission ) ;
87+ }
6388
64- // Dashboard auth : verify the requesting user is a member of the
65- // buffered run's org. The API path scopes by env id from the
66- // authenticated request; the dashboard route uses org-membership
67- // because the URL doesn't carry an envId.
68- const member = await prisma . orgMember . findFirst ( {
69- where : { userId, organizationId : entry . orgId } ,
70- select : { id : true } ,
71- } ) ;
72- if ( ! member ) {
73- submission . error = { runParam : [ "Run not found" ] } ;
74- return json ( submission ) ;
75- }
89+ // Tenancy : verify the requesting user is a member of the buffered
90+ // run's org. The API path scopes by env id from the authenticated
91+ // request; the dashboard route uses org-membership because the URL
92+ // doesn't carry an envId.
93+ const member = await prisma . orgMember . findFirst ( {
94+ where : { userId : user . id , organizationId : entry . orgId } ,
95+ select : { id : true } ,
96+ } ) ;
97+ if ( ! member ) {
98+ submission . error = { runParam : [ "Run not found" ] } ;
99+ return json ( submission ) ;
100+ }
76101
77- const result = await buffer ! . mutateSnapshot ( runParam , {
78- type : "mark_cancelled" ,
79- cancelledAt : new Date ( ) . toISOString ( ) ,
80- cancelReason : "Canceled by user" ,
81- } ) ;
82- if ( result === "applied_to_snapshot" ) {
83- return redirectWithSuccessMessage ( submission . value . redirectUrl , request , `Canceled run` ) ;
84- }
85- // "not_found" or "busy" — both indicate the drainer raced us between
86- // the getEntry check above and mutateSnapshot. On "not_found" the
87- // entry was just popped and the PG row is in flight; on "busy" the
88- // drainer is mid-materialisation. Either way the customer should
89- // retry — by then the PG row exists and the regular cancel path at
90- // the top of this action takes over.
91- return redirectWithErrorMessage (
92- submission . value . redirectUrl ,
93- request ,
94- "Run is materialising — retry in a moment"
95- ) ;
96- } catch ( error ) {
97- if ( error instanceof Error ) {
98- logger . error ( "Failed to cancel run" , {
99- error : {
100- name : error . name ,
101- message : error . message ,
102- stack : error . stack ,
103- } ,
102+ const result = await buffer ! . mutateSnapshot ( runParam , {
103+ type : "mark_cancelled" ,
104+ cancelledAt : new Date ( ) . toISOString ( ) ,
105+ cancelReason : "Canceled by user" ,
104106 } ) ;
107+ if ( result === "applied_to_snapshot" ) {
108+ return redirectWithSuccessMessage ( submission . value . redirectUrl , request , `Canceled run` ) ;
109+ }
110+ // "not_found" or "busy" — both indicate the drainer raced us between
111+ // the getEntry check above and mutateSnapshot. On "not_found" the
112+ // entry was just popped and the PG row is in flight; on "busy" the
113+ // drainer is mid-materialisation. Either way the customer should
114+ // retry — by then the PG row exists and the regular cancel path at
115+ // the top of this action takes over.
105116 return redirectWithErrorMessage (
106117 submission . value . redirectUrl ,
107118 request ,
108- `Failed to cancel run, ${ error . message } `
109- ) ;
110- } else {
111- logger . error ( "Failed to cancel run" , { error } ) ;
112- return redirectWithErrorMessage (
113- submission . value . redirectUrl ,
114- request ,
115- `Failed to cancel run, ${ JSON . stringify ( error ) } `
119+ "Run is materialising — retry in a moment"
116120 ) ;
121+ } catch ( error ) {
122+ if ( error instanceof Error ) {
123+ logger . error ( "Failed to cancel run" , {
124+ error : {
125+ name : error . name ,
126+ message : error . message ,
127+ stack : error . stack ,
128+ } ,
129+ } ) ;
130+ return redirectWithErrorMessage (
131+ submission . value . redirectUrl ,
132+ request ,
133+ `Failed to cancel run, ${ error . message } `
134+ ) ;
135+ } else {
136+ logger . error ( "Failed to cancel run" , { error } ) ;
137+ return redirectWithErrorMessage (
138+ submission . value . redirectUrl ,
139+ request ,
140+ `Failed to cancel run, ${ JSON . stringify ( error ) } `
141+ ) ;
142+ }
117143 }
118144 }
119- } ;
145+ ) ;
0 commit comments