Skip to content

Commit 2856662

Browse files
authored
ci(webapp): wire the run-ops legacy guard into CI and add oxlint residency fences (#4279)
## What - Runs `apps/webapp/scripts/runOpsLegacyGuard.ts --check` as its own PR job (`runops-guard`), so code that reaches a run-graph table through the control-plane Prisma client instead of the RunStore fails the build. - Adds a `trigger-runops` oxlint plugin with two fast, in-editor rules scoped to `apps/webapp/app`: one for direct `prisma.taskRun`-style access, one for a control-plane client wired into a read-through slot. These are the cheap fence; the guard is the type-aware gate. - Fixes `CancelTaskRunService.callV1`: historical V1 runs are legacy-resident, so its two finalize writes now go through `runOpsLegacyPrisma` instead of the control-plane client (they'd miss the row once legacy is a separate database). - Regenerates the guard baseline, which had drifted stale (it referenced files deleted in an earlier PR). ## Why The guard existed but ran nowhere, so its baseline rotted and a real residency gap (the V1 cancel writes) sat undetected. Wiring it into CI turns it into a ratchet against new control-plane run-graph access. ## Verification Local, against a clean regen: `oxfmt --check`, `oxlint .`, `guard --check`, and `typecheck --filter webapp` all pass. Remaining baseline entries are 4 batch-results router reads through type-opaque `as PrismaReplicaClient` casts (correct at runtime, accepted) + 2 sanctioned legacy annotations.
1 parent 8219721 commit 2856662

7 files changed

Lines changed: 406 additions & 35 deletions

File tree

.github/workflows/pr_checks.yml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ jobs:
5656
- '.github/workflows/pr_checks.yml'
5757
- '.github/workflows/unit-tests-webapp.yml'
5858
- '.github/workflows/e2e-webapp.yml'
59+
- '.github/workflows/runops-guard.yml'
5960
- '.configs/**'
6061
- 'package.json'
6162
- 'pnpm-lock.yaml'
@@ -111,6 +112,11 @@ jobs:
111112
if: needs.changes.outputs.code == 'true' || needs.changes.outputs.typecheck_self == 'true'
112113
uses: ./.github/workflows/typecheck.yml
113114

115+
runops-guard:
116+
needs: changes
117+
if: needs.changes.outputs.webapp == 'true'
118+
uses: ./.github/workflows/runops-guard.yml
119+
114120
webapp:
115121
needs: changes
116122
if: needs.changes.outputs.webapp == 'true'
@@ -161,6 +167,7 @@ jobs:
161167
- changes
162168
- code-quality
163169
- typecheck
170+
- runops-guard
164171
- webapp
165172
- e2e-webapp
166173
- packages

.github/workflows/runops-guard.yml

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
name: "🛡️ Run-ops Legacy Guard"
2+
3+
on:
4+
workflow_call:
5+
6+
permissions:
7+
contents: read
8+
9+
jobs:
10+
runops-guard:
11+
runs-on: warp-ubuntu-latest-x64-16x
12+
13+
steps:
14+
- name: ⬇️ Checkout repo
15+
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
16+
with:
17+
fetch-depth: 0
18+
persist-credentials: false
19+
20+
- name: ⎔ Setup pnpm
21+
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
22+
with:
23+
version: 10.33.2
24+
25+
- name: ⎔ Setup node
26+
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
27+
with:
28+
node-version: 24.18.0
29+
cache: "pnpm"
30+
31+
- name: 📥 Download deps
32+
run: pnpm install --frozen-lockfile
33+
34+
- name: 📀 Generate Prisma Client
35+
run: pnpm run generate
36+
37+
- name: 🛡️ Run-ops legacy guard
38+
run: pnpm --filter webapp run guard:runops-legacy -- --check

.oxlintrc.json

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
{
22
"$schema": "./node_modules/oxlint/configuration_schema.json",
33
"plugins": ["typescript", "import", "react"],
4-
"jsPlugins": ["./oxlint-plugins/no-thrown-unawaited-redirect.mjs"],
4+
"jsPlugins": [
5+
"./oxlint-plugins/no-thrown-unawaited-redirect.mjs",
6+
"./oxlint-plugins/runops-residency.mjs"
7+
],
58
"ignorePatterns": [
69
"**/dist/**",
710
"**/build/**",
@@ -34,5 +37,21 @@
3437
"react-hooks/exhaustive-deps": "off",
3538
"react-hooks/rules-of-hooks": "off",
3639
"trigger/no-thrown-unawaited-redirect": "error"
37-
}
40+
},
41+
"overrides": [
42+
{
43+
"files": ["apps/webapp/app/**/*.ts", "apps/webapp/app/**/*.tsx"],
44+
"rules": {
45+
"trigger-runops/no-control-plane-run-graph-access": "error",
46+
"trigger-runops/no-control-plane-in-runops-slot": "error"
47+
}
48+
},
49+
{
50+
"files": ["apps/webapp/app/**/*.test.ts", "apps/webapp/app/**/*.test.tsx"],
51+
"rules": {
52+
"trigger-runops/no-control-plane-run-graph-access": "off",
53+
"trigger-runops/no-control-plane-in-runops-slot": "off"
54+
}
55+
}
56+
]
3857
}

apps/webapp/app/v3/runOpsMigration/track1-baseline.json

Lines changed: 36 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -68,46 +68,52 @@
6868
"WaitpointTag.project"
6969
],
7070
"totals": {
71-
"violations": 0,
72-
"detectorI": 0,
71+
"violations": 4,
72+
"detectorI": 4,
7373
"detectorII": 0,
7474
"detectorIII": 0,
7575
"write": 0,
76-
"read": 0,
77-
"files": 0,
78-
"legacyAnnotations": 5
76+
"read": 4,
77+
"files": 1,
78+
"legacyAnnotations": 0
7979
},
80-
"violations": [],
81-
"legacyAnnotations": [
80+
"violations": [
8281
{
83-
"file": "apps/webapp/app/v3/services/completeAttempt.server.ts",
84-
"line": 302,
85-
"reason": "OOM machine bump on attempt-owning V1/cuid run",
86-
"receiver": "runOpsLegacyPrisma"
82+
"file": "apps/webapp/app/presenters/v3/ApiBatchResultsPresenter.server.ts",
83+
"line": 88,
84+
"model": "BatchTaskRun",
85+
"delegate": "batchTaskRun",
86+
"callKind": "read",
87+
"detector": "i",
88+
"snippet": "const batchRun = await this._replica.batchTaskRun.findFirst({"
8789
},
8890
{
89-
"file": "apps/webapp/app/v3/services/completeAttempt.server.ts",
90-
"line": 360,
91-
"reason": "error on attempt-owning V1/cuid run",
92-
"receiver": "runOpsLegacyPrisma"
91+
"file": "apps/webapp/app/presenters/v3/ApiBatchResultsPresenter.server.ts",
92+
"line": 149,
93+
"model": "BatchTaskRun",
94+
"delegate": "batchTaskRun",
95+
"callKind": "read",
96+
"detector": "i",
97+
"snippet": "client.batchTaskRun.findFirst({"
9398
},
9499
{
95-
"file": "apps/webapp/app/v3/services/completeAttempt.server.ts",
96-
"line": 604,
97-
"reason": "RETRYING status on attempt-owning V1/cuid run",
98-
"receiver": "runOpsLegacyPrisma"
100+
"file": "apps/webapp/app/presenters/v3/ApiBatchResultsPresenter.server.ts",
101+
"line": 183,
102+
"model": "TaskRun",
103+
"delegate": "taskRun",
104+
"callKind": "read",
105+
"detector": "i",
106+
"snippet": "const newRows = (await newClient.taskRun.findMany({"
99107
},
100108
{
101-
"file": "apps/webapp/app/v3/services/createTaskRunAttempt.server.ts",
102-
"line": 168,
103-
"reason": "run-bump inside legacy attempt-create tx (V1-only path)",
104-
"receiver": "tx"
105-
},
106-
{
107-
"file": "apps/webapp/app/v3/services/executeTasksWaitingForDeploy.ts",
108-
"line": 92,
109-
"reason": "post ownerEngine()!==\"NEW\" filter",
110-
"receiver": "runOpsLegacyPrisma"
109+
"file": "apps/webapp/app/presenters/v3/ApiBatchResultsPresenter.server.ts",
110+
"line": 195,
111+
"model": "TaskRun",
112+
"delegate": "taskRun",
113+
"callKind": "read",
114+
"detector": "i",
115+
"snippet": "const legacyRows = (await legacyReplica.taskRun.findMany({"
111116
}
112-
]
117+
],
118+
"legacyAnnotations": []
113119
}

apps/webapp/app/v3/services/cancelTaskRun.server.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { RunEngineVersion, type TaskRun } from "@trigger.dev/database";
2+
import { runOpsLegacyPrismaClient } from "~/db.server";
23
import { engine } from "../runEngine.server";
34
import { isCancellableRunStatus } from "../taskStatus";
45
import { BaseService } from "./baseService.server";
@@ -43,15 +44,15 @@ export class CancelTaskRunService extends BaseService {
4344
// DB row directly. Never throw here: the cancel route returns 500 on any throw.
4445
if (!isCancellableRunStatus(taskRun.status)) {
4546
if (options?.bulkActionId) {
46-
await this._prisma.taskRun.update({
47+
await runOpsLegacyPrismaClient.taskRun.update({
4748
where: { id: taskRun.id },
4849
data: { bulkActionGroupIds: { push: options.bulkActionId } },
4950
});
5051
}
5152
return { id: taskRun.id, alreadyFinished: true };
5253
}
5354

54-
await this._prisma.taskRun.update({
55+
await runOpsLegacyPrismaClient.taskRun.update({
5556
where: { id: taskRun.id },
5657
data: {
5758
status: "CANCELED",

apps/webapp/scripts/runOpsLegacyGuard.ts

Lines changed: 145 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,41 @@ const RUNOPS_SCHEMA = path.join(
4545
);
4646
const BASELINE_PATH = path.join(WEBAPP_DIR, "app", "v3", "runOpsMigration", "track1-baseline.json");
4747

48+
const CP_PACKAGE_DIR = path.dirname(path.dirname(CP_SCHEMA));
49+
const RUNOPS_PACKAGE_DIR = path.dirname(path.dirname(RUNOPS_SCHEMA));
50+
51+
/** Absolute path of the generated Prisma client for a schema, from its generator `output = "..."`. */
52+
function generatedClientDir(schemaFile: string): string {
53+
const m = /^\s*output\s*=\s*"([^"]+)"/m.exec(fs.readFileSync(schemaFile, "utf8"));
54+
if (!m) {
55+
console.error(`No generator output path found in ${schemaFile}`);
56+
process.exit(2);
57+
}
58+
return path.resolve(path.dirname(schemaFile), m[1]);
59+
}
60+
61+
const CP_GENERATED_DIR = generatedClientDir(CP_SCHEMA);
62+
const RUNOPS_GENERATED_DIR = generatedClientDir(RUNOPS_SCHEMA);
63+
64+
function realpathIfExists(p: string): string {
65+
try {
66+
return fs.realpathSync(p);
67+
} catch {
68+
return p;
69+
}
70+
}
71+
72+
// Classification roots, realpath'd to match the checker's realpath'd file names.
73+
const RUNOPS_DECL_DIRS = [RUNOPS_GENERATED_DIR, RUNOPS_PACKAGE_DIR].map(realpathIfExists);
74+
const CP_DECL_DIRS = [CP_GENERATED_DIR, CP_PACKAGE_DIR].map(realpathIfExists);
75+
76+
// Both client packages are force-resolved to SOURCE (src/ + generated client) when building the
77+
// program, so classification never depends on a built/fresh `dist/` in the running environment.
78+
const FORCED_TYPE_RESOLUTIONS = new Map<string, string>([
79+
["@trigger.dev/database", path.join(CP_PACKAGE_DIR, "src", "index.ts")],
80+
["@internal/run-ops-database", path.join(RUNOPS_PACKAGE_DIR, "src", "index.ts")],
81+
]);
82+
4883
// Files excluded from the sweep. V1-only files come from .claude/rules/legacy-v3-code.md.
4984
const V1_FILES = new Set(
5085
[
@@ -254,7 +289,41 @@ function buildProgram(): ts.Program {
254289
};
255290
const parsed = ts.getParsedCommandLineOfConfigFile(TSCONFIG_PATH, undefined, host);
256291
if (!parsed) throw new Error(`Failed to parse ${TSCONFIG_PATH}`);
257-
return ts.createProgram({ rootNames: parsed.fileNames, options: parsed.options });
292+
// Resolve workspace packages from source (tsconfig.check.json blanks this to target built dists,
293+
// which may not exist here), and pin the two client packages to their src entrypoints —
294+
// @trigger.dev/database has no exports map, so the condition alone cannot redirect it.
295+
const options: ts.CompilerOptions = {
296+
...parsed.options,
297+
customConditions: ["@triggerdotdev/source"],
298+
};
299+
const compilerHost = ts.createCompilerHost(options);
300+
const resolutionCache = ts.createModuleResolutionCache(
301+
compilerHost.getCurrentDirectory(),
302+
(f) => compilerHost.getCanonicalFileName(f),
303+
options
304+
);
305+
compilerHost.resolveModuleNameLiterals = (moduleLiterals, containingFile, redirected, opts) =>
306+
moduleLiterals.map((lit) => {
307+
const forced = FORCED_TYPE_RESOLUTIONS.get(lit.text);
308+
if (forced) {
309+
return {
310+
resolvedModule: {
311+
resolvedFileName: forced,
312+
extension: ts.Extension.Ts,
313+
isExternalLibraryImport: false,
314+
},
315+
};
316+
}
317+
return ts.resolveModuleName(
318+
lit.text,
319+
containingFile,
320+
opts,
321+
compilerHost,
322+
resolutionCache,
323+
redirected
324+
);
325+
});
326+
return ts.createProgram({ rootNames: parsed.fileNames, options, host: compilerHost });
258327
}
259328

260329
// ─────────────────────────────────────────────────────────────────────────────
@@ -263,7 +332,17 @@ function buildProgram(): ts.Program {
263332

264333
type ClientKind = "cp" | "runops" | "other";
265334

335+
function underDir(file: string, dir: string): boolean {
336+
const rel = path.relative(dir, file);
337+
return rel !== "" && !rel.startsWith("..") && !path.isAbsolute(rel);
338+
}
339+
340+
// Absolute-dir match against the two packages first (environment-independent); substring match is
341+
// only a fallback for non-workspace layouts (pnpm store paths) and the virtual self-test files.
266342
function declFileKind(fileName: string): ClientKind | undefined {
343+
const abs = path.resolve(fileName);
344+
if (RUNOPS_DECL_DIRS.some((d) => underDir(abs, d))) return "runops";
345+
if (CP_DECL_DIRS.some((d) => underDir(abs, d))) return "cp";
267346
const f = fileName.split(path.sep).join("/");
268347
if (f.includes("run-ops-database")) return "runops";
269348
if (f.includes("internal-packages/database")) return "cp";
@@ -629,9 +708,65 @@ function collectCrossSeamHits(
629708
// Scan
630709
// ─────────────────────────────────────────────────────────────────────────────
631710

711+
// Known-kind receivers in db.server.ts. If either fails to classify, the checker cannot see the
712+
// client types (e.g. generated clients missing) and every verdict would be a silent false-clean,
713+
// so the guard refuses to report instead of rubber-stamping.
714+
const CLASSIFICATION_ANCHORS: Array<{ name: string; expected: ClientKind }> = [
715+
{ name: "runOpsLegacyPrisma", expected: "cp" },
716+
{ name: "runOpsNewPrismaClient", expected: "runops" },
717+
];
718+
719+
function assertClassificationAnchors(program: ts.Program, checker: ts.TypeChecker): void {
720+
const anchorFile = path.join(WEBAPP_DIR, "app", "db.server.ts");
721+
const sf = program.getSourceFile(anchorFile);
722+
const problems: string[] = [];
723+
if (!sf) {
724+
problems.push(`${repoRel(anchorFile)} is not part of the program`);
725+
} else {
726+
for (const anchor of CLASSIFICATION_ANCHORS) {
727+
let ident: ts.Identifier | undefined;
728+
const find = (node: ts.Node): void => {
729+
if (ident) return;
730+
if (
731+
ts.isVariableDeclaration(node) &&
732+
ts.isIdentifier(node.name) &&
733+
node.name.text === anchor.name
734+
) {
735+
ident = node.name;
736+
return;
737+
}
738+
ts.forEachChild(node, find);
739+
};
740+
find(sf);
741+
if (!ident) {
742+
problems.push(`anchor "${anchor.name}" not found in ${repoRel(anchorFile)}`);
743+
continue;
744+
}
745+
const receiverType = checker.getTypeAtLocation(ident);
746+
const delegateSym = receiverType.getProperty("taskRun");
747+
const got = delegateSym
748+
? classifyType(checker, checker.getTypeOfSymbolAtLocation(delegateSym, ident))
749+
: "unresolved";
750+
if (got !== anchor.expected) {
751+
problems.push(`${anchor.name}.taskRun classified "${got}", expected "${anchor.expected}"`);
752+
}
753+
}
754+
}
755+
if (problems.length) {
756+
console.error(
757+
`[runops-guard] CLASSIFICATION ANCHORS FAILED — the guard cannot distinguish the ` +
758+
`control-plane and run-ops clients in this environment, so its verdicts would be ` +
759+
`meaningless:\n ${problems.join("\n ")}\n` +
760+
`Ensure the generated Prisma clients exist (pnpm run generate) and retry.`
761+
);
762+
process.exit(2);
763+
}
764+
}
765+
632766
function scan(): ScanResult {
633767
const program = buildProgram();
634768
const checker = program.getTypeChecker();
769+
assertClassificationAnchors(program, checker);
635770
return scanProgram(program, checker, isInScope);
636771
}
637772

@@ -1134,6 +1269,15 @@ function main(): void {
11341269
process.exit(2);
11351270
}
11361271

1272+
for (const dir of [CP_GENERATED_DIR, RUNOPS_GENERATED_DIR]) {
1273+
if (!fs.existsSync(path.join(dir, "index.d.ts"))) {
1274+
console.error(
1275+
`[runops-guard] generated Prisma client missing at ${repoRel(dir)} — run: pnpm run generate`
1276+
);
1277+
process.exit(2);
1278+
}
1279+
}
1280+
11371281
console.error(`[runops-guard] repo root: ${REPO_ROOT}`);
11381282
console.error(
11391283
`[runops-guard] run-graph delegates: ${Array.from(RUN_GRAPH_DELEGATES).join(", ")}`

0 commit comments

Comments
 (0)