diff --git a/.github/workflows/pr_checks.yml b/.github/workflows/pr_checks.yml index 2629ff8243c..b0fbd6ac040 100644 --- a/.github/workflows/pr_checks.yml +++ b/.github/workflows/pr_checks.yml @@ -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 diff --git a/.github/workflows/runops-guard.yml b/.github/workflows/runops-guard.yml new file mode 100644 index 00000000000..e496db2a93d --- /dev/null +++ b/.github/workflows/runops-guard.yml @@ -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 diff --git a/.oxlintrc.json b/.oxlintrc.json index e32d206dc38..d9b4cb2171f 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -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" + } + } + ] } diff --git a/apps/webapp/app/v3/runOpsMigration/track1-baseline.json b/apps/webapp/app/v3/runOpsMigration/track1-baseline.json index ed801775c12..9127f2d48ee 100644 --- a/apps/webapp/app/v3/runOpsMigration/track1-baseline.json +++ b/apps/webapp/app/v3/runOpsMigration/track1-baseline.json @@ -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": [] } diff --git a/apps/webapp/app/v3/services/cancelTaskRun.server.ts b/apps/webapp/app/v3/services/cancelTaskRun.server.ts index 29937366784..543e303e364 100644 --- a/apps/webapp/app/v3/services/cancelTaskRun.server.ts +++ b/apps/webapp/app/v3/services/cancelTaskRun.server.ts @@ -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", diff --git a/apps/webapp/scripts/runOpsLegacyGuard.ts b/apps/webapp/scripts/runOpsLegacyGuard.ts index a8accc38716..d56d2035511 100644 --- a/apps/webapp/scripts/runOpsLegacyGuard.ts +++ b/apps/webapp/scripts/runOpsLegacyGuard.ts @@ -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([ + ["@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(", ")}` diff --git a/oxlint-plugins/runops-residency.mjs b/oxlint-plugins/runops-residency.mjs new file mode 100644 index 00000000000..177c9828f66 --- /dev/null +++ b/oxlint-plugins/runops-residency.mjs @@ -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;