Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .github/workflows/pr_checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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'
Expand Down Expand Up @@ -161,6 +167,7 @@ jobs:
- changes
- code-quality
- typecheck
- runops-guard
- webapp
- e2e-webapp
- packages
Expand Down
38 changes: 38 additions & 0 deletions .github/workflows/runops-guard.yml
Original file line number Diff line number Diff line change
@@ -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
23 changes: 21 additions & 2 deletions .oxlintrc.json
Original file line number Diff line number Diff line change
@@ -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/**",
Expand Down Expand Up @@ -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"
}
}
]
}
66 changes: 36 additions & 30 deletions apps/webapp/app/v3/runOpsMigration/track1-baseline.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": []
}
5 changes: 3 additions & 2 deletions apps/webapp/app/v3/services/cancelTaskRun.server.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -43,15 +44,15 @@ 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 } },
});
}
return { id: taskRun.id, alreadyFinished: true };
}

await this._prisma.taskRun.update({
await runOpsLegacyPrismaClient.taskRun.update({
where: { id: taskRun.id },
data: {
status: "CANCELED",
Expand Down
146 changes: 145 additions & 1 deletion apps/webapp/scripts/runOpsLegacyGuard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
[
Expand Down Expand Up @@ -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 });
}

// ─────────────────────────────────────────────────────────────────────────────
Expand All @@ -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";
Expand Down Expand Up @@ -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 }> = [
Comment thread
coderabbitai[bot] marked this conversation as resolved.
{ 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);
}

Expand Down Expand Up @@ -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(", ")}`
Expand Down
Loading