@@ -27,6 +27,11 @@ import type { AuthenticatedEnvironment } from "@trigger.dev/core/v3/auth/environ
2727 * so a `ResolvedEngineEnv` is a structural supertype of it and can be passed directly
2828 * to `enqueueSystem.enqueueRun({ env })`. `concurrencyLimitBurstFactor` stays a
2929 * `Prisma.Decimal` (do NOT coerce); `maximumConcurrencyLimit` is non-null per schema.
30+ *
31+ * `projectDeletedAt`/`organizationDeletedAt` are the soft-delete tombstones the engine gates
32+ * enqueue and dequeue on, so a deleted project's work stops instead of running weeks later.
33+ * Every implementation of this interface MUST populate them; a resolver that leaves them null
34+ * silently disables both guards.
3035 */
3136export type ResolvedEngineEnv = {
3237 id : string ;
@@ -36,10 +41,48 @@ export type ResolvedEngineEnv = {
3641 concurrencyLimitBurstFactor : Prisma . Decimal ;
3742 projectId : string ;
3843 organizationId : string ;
44+ projectDeletedAt : Date | null ;
45+ organizationDeletedAt : Date | null ;
3946 project : { id : string } ;
4047 organization : { id : string } ;
4148} ;
4249
50+ /**
51+ * Just the soft-delete tombstones, read from the authoritative source rather than from
52+ * whatever `resolveEnv` has cached.
53+ */
54+ export type EnvDeletionState = Pick <
55+ ResolvedEngineEnv ,
56+ "projectDeletedAt" | "organizationDeletedAt"
57+ > ;
58+
59+ /**
60+ * Recorded when the environment row itself is absent, so there is no tombstone to read. Only
61+ * ever reached from an authoritative read: a cached absence is not trustworthy enough to end
62+ * a run on.
63+ */
64+ export const MISSING_ENVIRONMENT_REASON = "The environment was deleted" ;
65+
66+ /**
67+ * The reason a run on this environment must not execute, or `null` when it may. Doubles as the
68+ * enqueue/dequeue guard and as the cancellation reason recorded on the run, so the two call
69+ * sites cannot drift apart.
70+ */
71+ export function deletedEnvironmentReason ( env : {
72+ projectDeletedAt : Date | null ;
73+ organizationDeletedAt : Date | null ;
74+ } ) : string | null {
75+ if ( env . organizationDeletedAt ) {
76+ return "The organization was deleted" ;
77+ }
78+
79+ if ( env . projectDeletedAt ) {
80+ return "The project was deleted" ;
81+ }
82+
83+ return null ;
84+ }
85+
4386/**
4487 * The richer control-plane env primitive: the slim, structural
4588 * `AuthenticatedEnvironment` (slug/branchName/project/organization/…) PLUS the
@@ -127,6 +170,14 @@ export type WorkerVersionDispatchFilter = {
127170
128171export interface ControlPlaneResolver {
129172 resolveEnv ( environmentId : string ) : Promise < ResolvedEngineEnv | null > ;
173+ /**
174+ * The deletion tombstones read straight from the authoritative control-plane source, never
175+ * from a cache. An implementation may serve `resolveEnv` from a per-process cache whose
176+ * invalidation does not reach other processes, so a decision that must not act on
177+ * pre-deletion state for even a moment reads it here instead. Returns `null` when the
178+ * environment does not exist.
179+ */
180+ resolveEnvDeletionState ( environmentId : string ) : Promise < EnvDeletionState | null > ;
130181 resolveAuthenticatedEnv ( environmentId : string ) : Promise < ResolvedAuthenticatedEnv | null > ;
131182 resolveWorkerVersion (
132183 args : {
@@ -174,8 +225,8 @@ export class PassthroughControlPlaneResolver implements ControlPlaneResolver {
174225 maximumConcurrencyLimit : true ,
175226 concurrencyLimitBurstFactor : true ,
176227 projectId : true ,
177- project : { select : { id : true , organizationId : true } } ,
178- organization : { select : { id : true } } ,
228+ project : { select : { id : true , organizationId : true , deletedAt : true } } ,
229+ organization : { select : { id : true , deletedAt : true } } ,
179230 } ,
180231 } ) ;
181232
@@ -191,11 +242,38 @@ export class PassthroughControlPlaneResolver implements ControlPlaneResolver {
191242 concurrencyLimitBurstFactor : env . concurrencyLimitBurstFactor ,
192243 projectId : env . projectId ,
193244 organizationId : env . project . organizationId ,
245+ projectDeletedAt : env . project . deletedAt ,
246+ organizationDeletedAt : env . organization . deletedAt ,
194247 project : { id : env . project . id } ,
195248 organization : { id : env . organization . id } ,
196249 } ;
197250 }
198251
252+ /**
253+ * Passthrough holds no cache, so `resolveEnv` is already authoritative and this exists to
254+ * satisfy the seam. It reads the org tombstone through `project.organization` (the same path
255+ * the webapp resolver uses) rather than the environment's own organization relation.
256+ */
257+ async resolveEnvDeletionState ( environmentId : string ) : Promise < EnvDeletionState | null > {
258+ const env = await this . #prisma. runtimeEnvironment . findFirst ( {
259+ where : { id : environmentId } ,
260+ select : {
261+ project : {
262+ select : { deletedAt : true , organization : { select : { deletedAt : true } } } ,
263+ } ,
264+ } ,
265+ } ) ;
266+
267+ if ( ! env ) {
268+ return null ;
269+ }
270+
271+ return {
272+ projectDeletedAt : env . project . deletedAt ,
273+ organizationDeletedAt : env . project . organization . deletedAt ,
274+ } ;
275+ }
276+
199277 async resolveAuthenticatedEnv ( environmentId : string ) : Promise < ResolvedAuthenticatedEnv | null > {
200278 const env = await this . #prisma. runtimeEnvironment . findFirst ( {
201279 where : { id : environmentId } ,
0 commit comments