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.
This commit is contained in:
Daniel Sutton
2026-07-17 16:27:30 +01:00
committed by GitHub
parent 821972176d
commit 285666290f
7 changed files with 406 additions and 35 deletions
+7
View File
@@ -56,6 +56,7 @@ jobs:
- '.github/workflows/pr_checks.yml'
- '.github/workflows/unit-tests-webapp.yml'
- '.github/workflows/e2e-webapp.yml'
- '.github/workflows/runops-guard.yml'
- '.configs/**'
- 'package.json'
- 'pnpm-lock.yaml'
@@ -111,6 +112,11 @@ jobs:
if: needs.changes.outputs.code == 'true' || needs.changes.outputs.typecheck_self == 'true'
uses: ./.github/workflows/typecheck.yml
runops-guard:
needs: changes
if: needs.changes.outputs.webapp == 'true'
uses: ./.github/workflows/runops-guard.yml
webapp:
needs: changes
if: needs.changes.outputs.webapp == 'true'
@@ -161,6 +167,7 @@ jobs:
- changes
- code-quality
- typecheck
- runops-guard
- webapp
- e2e-webapp
- packages
+38
View File
@@ -0,0 +1,38 @@
name: "🛡️ Run-ops Legacy Guard"
on:
workflow_call:
permissions:
contents: read
jobs:
runops-guard:
runs-on: warp-ubuntu-latest-x64-16x
steps:
- name: ⬇️ Checkout repo
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
fetch-depth: 0
persist-credentials: false
- name: ⎔ Setup pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
with:
version: 10.33.2
- name: ⎔ Setup node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 24.18.0
cache: "pnpm"
- name: 📥 Download deps
run: pnpm install --frozen-lockfile
- name: 📀 Generate Prisma Client
run: pnpm run generate
- name: 🛡️ Run-ops legacy guard
run: pnpm --filter webapp run guard:runops-legacy -- --check
+21 -2
View File
@@ -1,7 +1,10 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["typescript", "import", "react"],
"jsPlugins": ["./oxlint-plugins/no-thrown-unawaited-redirect.mjs"],
"jsPlugins": [
"./oxlint-plugins/no-thrown-unawaited-redirect.mjs",
"./oxlint-plugins/runops-residency.mjs"
],
"ignorePatterns": [
"**/dist/**",
"**/build/**",
@@ -34,5 +37,21 @@
"react-hooks/exhaustive-deps": "off",
"react-hooks/rules-of-hooks": "off",
"trigger/no-thrown-unawaited-redirect": "error"
}
},
"overrides": [
{
"files": ["apps/webapp/app/**/*.ts", "apps/webapp/app/**/*.tsx"],
"rules": {
"trigger-runops/no-control-plane-run-graph-access": "error",
"trigger-runops/no-control-plane-in-runops-slot": "error"
}
},
{
"files": ["apps/webapp/app/**/*.test.ts", "apps/webapp/app/**/*.test.tsx"],
"rules": {
"trigger-runops/no-control-plane-run-graph-access": "off",
"trigger-runops/no-control-plane-in-runops-slot": "off"
}
}
]
}
@@ -68,46 +68,52 @@
"WaitpointTag.project"
],
"totals": {
"violations": 0,
"detectorI": 0,
"violations": 4,
"detectorI": 4,
"detectorII": 0,
"detectorIII": 0,
"write": 0,
"read": 0,
"files": 0,
"legacyAnnotations": 5
"read": 4,
"files": 1,
"legacyAnnotations": 0
},
"violations": [],
"legacyAnnotations": [
"violations": [
{
"file": "apps/webapp/app/v3/services/completeAttempt.server.ts",
"line": 302,
"reason": "OOM machine bump on attempt-owning V1/cuid run",
"receiver": "runOpsLegacyPrisma"
"file": "apps/webapp/app/presenters/v3/ApiBatchResultsPresenter.server.ts",
"line": 88,
"model": "BatchTaskRun",
"delegate": "batchTaskRun",
"callKind": "read",
"detector": "i",
"snippet": "const batchRun = await this._replica.batchTaskRun.findFirst({"
},
{
"file": "apps/webapp/app/v3/services/completeAttempt.server.ts",
"line": 360,
"reason": "error on attempt-owning V1/cuid run",
"receiver": "runOpsLegacyPrisma"
"file": "apps/webapp/app/presenters/v3/ApiBatchResultsPresenter.server.ts",
"line": 149,
"model": "BatchTaskRun",
"delegate": "batchTaskRun",
"callKind": "read",
"detector": "i",
"snippet": "client.batchTaskRun.findFirst({"
},
{
"file": "apps/webapp/app/v3/services/completeAttempt.server.ts",
"line": 604,
"reason": "RETRYING status on attempt-owning V1/cuid run",
"receiver": "runOpsLegacyPrisma"
"file": "apps/webapp/app/presenters/v3/ApiBatchResultsPresenter.server.ts",
"line": 183,
"model": "TaskRun",
"delegate": "taskRun",
"callKind": "read",
"detector": "i",
"snippet": "const newRows = (await newClient.taskRun.findMany({"
},
{
"file": "apps/webapp/app/v3/services/createTaskRunAttempt.server.ts",
"line": 168,
"reason": "run-bump inside legacy attempt-create tx (V1-only path)",
"receiver": "tx"
},
{
"file": "apps/webapp/app/v3/services/executeTasksWaitingForDeploy.ts",
"line": 92,
"reason": "post ownerEngine()!==\"NEW\" filter",
"receiver": "runOpsLegacyPrisma"
"file": "apps/webapp/app/presenters/v3/ApiBatchResultsPresenter.server.ts",
"line": 195,
"model": "TaskRun",
"delegate": "taskRun",
"callKind": "read",
"detector": "i",
"snippet": "const legacyRows = (await legacyReplica.taskRun.findMany({"
}
]
],
"legacyAnnotations": []
}
@@ -1,4 +1,5 @@
import { RunEngineVersion, type TaskRun } from "@trigger.dev/database";
import { runOpsLegacyPrismaClient } from "~/db.server";
import { engine } from "../runEngine.server";
import { isCancellableRunStatus } from "../taskStatus";
import { BaseService } from "./baseService.server";
@@ -43,7 +44,7 @@ export class CancelTaskRunService extends BaseService {
// DB row directly. Never throw here: the cancel route returns 500 on any throw.
if (!isCancellableRunStatus(taskRun.status)) {
if (options?.bulkActionId) {
await this._prisma.taskRun.update({
await runOpsLegacyPrismaClient.taskRun.update({
where: { id: taskRun.id },
data: { bulkActionGroupIds: { push: options.bulkActionId } },
});
@@ -51,7 +52,7 @@ export class CancelTaskRunService extends BaseService {
return { id: taskRun.id, alreadyFinished: true };
}
await this._prisma.taskRun.update({
await runOpsLegacyPrismaClient.taskRun.update({
where: { id: taskRun.id },
data: {
status: "CANCELED",
+145 -1
View File
@@ -45,6 +45,41 @@ const RUNOPS_SCHEMA = path.join(
);
const BASELINE_PATH = path.join(WEBAPP_DIR, "app", "v3", "runOpsMigration", "track1-baseline.json");
const CP_PACKAGE_DIR = path.dirname(path.dirname(CP_SCHEMA));
const RUNOPS_PACKAGE_DIR = path.dirname(path.dirname(RUNOPS_SCHEMA));
/** Absolute path of the generated Prisma client for a schema, from its generator `output = "..."`. */
function generatedClientDir(schemaFile: string): string {
const m = /^\s*output\s*=\s*"([^"]+)"/m.exec(fs.readFileSync(schemaFile, "utf8"));
if (!m) {
console.error(`No generator output path found in ${schemaFile}`);
process.exit(2);
}
return path.resolve(path.dirname(schemaFile), m[1]);
}
const CP_GENERATED_DIR = generatedClientDir(CP_SCHEMA);
const RUNOPS_GENERATED_DIR = generatedClientDir(RUNOPS_SCHEMA);
function realpathIfExists(p: string): string {
try {
return fs.realpathSync(p);
} catch {
return p;
}
}
// Classification roots, realpath'd to match the checker's realpath'd file names.
const RUNOPS_DECL_DIRS = [RUNOPS_GENERATED_DIR, RUNOPS_PACKAGE_DIR].map(realpathIfExists);
const CP_DECL_DIRS = [CP_GENERATED_DIR, CP_PACKAGE_DIR].map(realpathIfExists);
// Both client packages are force-resolved to SOURCE (src/ + generated client) when building the
// program, so classification never depends on a built/fresh `dist/` in the running environment.
const FORCED_TYPE_RESOLUTIONS = new Map<string, string>([
["@trigger.dev/database", path.join(CP_PACKAGE_DIR, "src", "index.ts")],
["@internal/run-ops-database", path.join(RUNOPS_PACKAGE_DIR, "src", "index.ts")],
]);
// Files excluded from the sweep. V1-only files come from .claude/rules/legacy-v3-code.md.
const V1_FILES = new Set(
[
@@ -254,7 +289,41 @@ function buildProgram(): ts.Program {
};
const parsed = ts.getParsedCommandLineOfConfigFile(TSCONFIG_PATH, undefined, host);
if (!parsed) throw new Error(`Failed to parse ${TSCONFIG_PATH}`);
return ts.createProgram({ rootNames: parsed.fileNames, options: parsed.options });
// Resolve workspace packages from source (tsconfig.check.json blanks this to target built dists,
// which may not exist here), and pin the two client packages to their src entrypoints —
// @trigger.dev/database has no exports map, so the condition alone cannot redirect it.
const options: ts.CompilerOptions = {
...parsed.options,
customConditions: ["@triggerdotdev/source"],
};
const compilerHost = ts.createCompilerHost(options);
const resolutionCache = ts.createModuleResolutionCache(
compilerHost.getCurrentDirectory(),
(f) => compilerHost.getCanonicalFileName(f),
options
);
compilerHost.resolveModuleNameLiterals = (moduleLiterals, containingFile, redirected, opts) =>
moduleLiterals.map((lit) => {
const forced = FORCED_TYPE_RESOLUTIONS.get(lit.text);
if (forced) {
return {
resolvedModule: {
resolvedFileName: forced,
extension: ts.Extension.Ts,
isExternalLibraryImport: false,
},
};
}
return ts.resolveModuleName(
lit.text,
containingFile,
opts,
compilerHost,
resolutionCache,
redirected
);
});
return ts.createProgram({ rootNames: parsed.fileNames, options, host: compilerHost });
}
// ─────────────────────────────────────────────────────────────────────────────
@@ -263,7 +332,17 @@ function buildProgram(): ts.Program {
type ClientKind = "cp" | "runops" | "other";
function underDir(file: string, dir: string): boolean {
const rel = path.relative(dir, file);
return rel !== "" && !rel.startsWith("..") && !path.isAbsolute(rel);
}
// Absolute-dir match against the two packages first (environment-independent); substring match is
// only a fallback for non-workspace layouts (pnpm store paths) and the virtual self-test files.
function declFileKind(fileName: string): ClientKind | undefined {
const abs = path.resolve(fileName);
if (RUNOPS_DECL_DIRS.some((d) => underDir(abs, d))) return "runops";
if (CP_DECL_DIRS.some((d) => underDir(abs, d))) return "cp";
const f = fileName.split(path.sep).join("/");
if (f.includes("run-ops-database")) return "runops";
if (f.includes("internal-packages/database")) return "cp";
@@ -629,9 +708,65 @@ function collectCrossSeamHits(
// Scan
// ─────────────────────────────────────────────────────────────────────────────
// Known-kind receivers in db.server.ts. If either fails to classify, the checker cannot see the
// client types (e.g. generated clients missing) and every verdict would be a silent false-clean,
// so the guard refuses to report instead of rubber-stamping.
const CLASSIFICATION_ANCHORS: Array<{ name: string; expected: ClientKind }> = [
{ name: "runOpsLegacyPrisma", expected: "cp" },
{ name: "runOpsNewPrismaClient", expected: "runops" },
];
function assertClassificationAnchors(program: ts.Program, checker: ts.TypeChecker): void {
const anchorFile = path.join(WEBAPP_DIR, "app", "db.server.ts");
const sf = program.getSourceFile(anchorFile);
const problems: string[] = [];
if (!sf) {
problems.push(`${repoRel(anchorFile)} is not part of the program`);
} else {
for (const anchor of CLASSIFICATION_ANCHORS) {
let ident: ts.Identifier | undefined;
const find = (node: ts.Node): void => {
if (ident) return;
if (
ts.isVariableDeclaration(node) &&
ts.isIdentifier(node.name) &&
node.name.text === anchor.name
) {
ident = node.name;
return;
}
ts.forEachChild(node, find);
};
find(sf);
if (!ident) {
problems.push(`anchor "${anchor.name}" not found in ${repoRel(anchorFile)}`);
continue;
}
const receiverType = checker.getTypeAtLocation(ident);
const delegateSym = receiverType.getProperty("taskRun");
const got = delegateSym
? classifyType(checker, checker.getTypeOfSymbolAtLocation(delegateSym, ident))
: "unresolved";
if (got !== anchor.expected) {
problems.push(`${anchor.name}.taskRun classified "${got}", expected "${anchor.expected}"`);
}
}
}
if (problems.length) {
console.error(
`[runops-guard] CLASSIFICATION ANCHORS FAILED — the guard cannot distinguish the ` +
`control-plane and run-ops clients in this environment, so its verdicts would be ` +
`meaningless:\n ${problems.join("\n ")}\n` +
`Ensure the generated Prisma clients exist (pnpm run generate) and retry.`
);
process.exit(2);
}
}
function scan(): ScanResult {
const program = buildProgram();
const checker = program.getTypeChecker();
assertClassificationAnchors(program, checker);
return scanProgram(program, checker, isInScope);
}
@@ -1134,6 +1269,15 @@ function main(): void {
process.exit(2);
}
for (const dir of [CP_GENERATED_DIR, RUNOPS_GENERATED_DIR]) {
if (!fs.existsSync(path.join(dir, "index.d.ts"))) {
console.error(
`[runops-guard] generated Prisma client missing at ${repoRel(dir)} — run: pnpm run generate`
);
process.exit(2);
}
}
console.error(`[runops-guard] repo root: ${REPO_ROOT}`);
console.error(
`[runops-guard] run-graph delegates: ${Array.from(RUN_GRAPH_DELEGATES).join(", ")}`
+156
View File
@@ -0,0 +1,156 @@
/**
* oxlint plugin: trigger-runops — fast in-editor fences for the run-ops DB split.
* Name-based ports of two detectors from the authoritative type-aware guard
* (apps/webapp/scripts/runOpsLegacyGuard.ts, run as `--check` in CI). Scoped to
* apps/webapp/app via .oxlintrc.json overrides, matching the guard's scope.
*/
// The 16 run-graph delegates (camelCased run-ops models). Authoritative source is
// internal-packages/run-ops-database/prisma/schema.prisma, which the tsx guard
// parses; kept as a literal here so a missing schema can never disable linting.
const RUN_GRAPH_DELEGATES = new Set([
"taskRun",
"taskRunExecutionSnapshot",
"taskRunCheckpoint",
"waitpoint",
"taskRunWaitpoint",
"waitpointRunConnection",
"completedWaitpoint",
"waitpointTag",
"taskRunTag",
"taskRunDependency",
"taskRunAttempt",
"batchTaskRun",
"batchTaskRunItem",
"batchTaskRunError",
"checkpoint",
"checkpointRestoreEvent",
]);
// Global control-plane client exports (from ~/db.server).
const CONTROL_PLANE_GLOBALS = new Set(["prisma", "$replica"]);
// Read-through config slots that MUST carry a run-ops client. Kept in sync with
// RUN_OPS_READTHROUGH_SLOTS in scripts/runOpsLegacyGuard.ts. `controlPlaneReplica`
// is intentionally absent — it is meant to be control-plane.
const RUN_OPS_READTHROUGH_SLOTS = new Set([
"newClient",
"newReplica",
"runOpsNew",
"legacyReplica",
"runOpsLegacyReplica",
]);
const CONTROL_PLANE_CLIENT_IDENTIFIERS = new Set(["$replica", "prisma"]);
const CONTROL_PLANE_THIS_FIELDS = new Set(["_replica", "_prisma"]);
// Parentheses are not nodes in oxlint's ESTree, so only type-only wrappers unwrap.
function unwrap(node) {
let current = node;
while (
current &&
(current.type === "TSAsExpression" ||
current.type === "TSSatisfiesExpression" ||
current.type === "TSNonNullExpression")
) {
current = current.expression;
}
return current;
}
function valueIsControlPlaneClient(node) {
const expr = unwrap(node);
if (!expr) return false;
if (expr.type === "Identifier") return CONTROL_PLANE_CLIENT_IDENTIFIERS.has(expr.name);
if (
expr.type === "MemberExpression" &&
!expr.computed &&
expr.object.type === "ThisExpression" &&
expr.property.type === "Identifier"
) {
return CONTROL_PLANE_THIS_FIELDS.has(expr.property.name);
}
return false;
}
function propertyKeyName(property) {
const key = property.key;
if (!key) return undefined;
if (key.type === "Identifier" && !property.computed) return key.name;
if (key.type === "Literal" && typeof key.value === "string") return key.value;
return undefined;
}
/** @type {import("eslint").Rule.RuleModule} */
const noControlPlaneRunGraphAccess = {
meta: {
type: "problem",
docs: {
description:
"Disallow reaching a run-graph table through the global control-plane Prisma client; route through the RunStore.",
},
messages: {
leak: 'Run-graph table "{{delegate}}" is reached through the control-plane client "{{client}}". Once run-ops is a separate database this reads/writes the wrong DB — route through the RunStore instead.',
},
schema: [],
},
create(context) {
return {
MemberExpression(node) {
if (node.computed || node.property.type !== "Identifier") return;
if (!RUN_GRAPH_DELEGATES.has(node.property.name)) return;
if (node.object.type !== "Identifier" || !CONTROL_PLANE_GLOBALS.has(node.object.name)) {
return;
}
context.report({
node,
messageId: "leak",
data: { delegate: node.property.name, client: node.object.name },
});
},
};
},
};
/** @type {import("eslint").Rule.RuleModule} */
const noControlPlaneInRunOpsSlot = {
meta: {
type: "problem",
docs: {
description:
"Disallow assigning a control-plane Prisma client into a run-ops read-through config slot (routes run-ops reads at the wrong database).",
},
messages: {
misroute:
'Run-ops read-through slot "{{slot}}" is assigned the control-plane client "{{client}}". This routes run-ops reads at the control-plane database. Assign the run-ops client instead.',
},
schema: [],
},
create(context) {
return {
Property(node) {
const slot = propertyKeyName(node);
if (!slot || !RUN_OPS_READTHROUGH_SLOTS.has(slot)) return;
if (node.shorthand) return; // `{ legacyReplica }` binds a same-named local, not a cp export.
if (!valueIsControlPlaneClient(node.value)) return;
const client =
node.value.type === "Identifier"
? node.value.name
: context.sourceCode.getText(unwrap(node.value));
context.report({ node: node.value, messageId: "misroute", data: { slot, client } });
},
};
},
};
/** @type {import("eslint").ESLint.Plugin} */
const plugin = {
// Distinct namespace: oxlint keys plugins by meta.name, so this cannot reuse
// "trigger" without clobbering no-thrown-unawaited-redirect.
meta: { name: "trigger-runops" },
rules: {
"no-control-plane-run-graph-access": noControlPlaneRunGraphAccess,
"no-control-plane-in-runops-slot": noControlPlaneInRunOpsSlot,
},
};
export default plugin;