From f9960a83cc8ab59870df3a8da5825930087839b8 Mon Sep 17 00:00:00 2001 From: Daniel Sutton Date: Fri, 17 Jul 2026 13:23:00 +0100 Subject: [PATCH 1/4] fix(webapp): route historical V1 cancel writes through the legacy run-ops handle CancelTaskRunService.callV1 finalizes historical (engine V1) runs, which are legacy-resident. It wrote them through the control-plane client, which misses the row once legacy run-ops data lives on its own database. Route those two writes through runOpsLegacyPrisma, matching every other legacy run-graph write. --- apps/webapp/app/v3/services/cancelTaskRun.server.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/apps/webapp/app/v3/services/cancelTaskRun.server.ts b/apps/webapp/app/v3/services/cancelTaskRun.server.ts index 2993736678..dcf23010ad 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 { runOpsLegacyPrisma } from "~/db.server"; import { engine } from "../runEngine.server"; import { isCancellableRunStatus } from "../taskStatus"; import { BaseService } from "./baseService.server"; @@ -43,7 +44,8 @@ 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({ + // runops-legacy-ok: bulk-action tag on a historical V1 run (legacy-resident) + await runOpsLegacyPrisma.taskRun.update({ where: { id: taskRun.id }, data: { bulkActionGroupIds: { push: options.bulkActionId } }, }); @@ -51,7 +53,8 @@ export class CancelTaskRunService extends BaseService { return { id: taskRun.id, alreadyFinished: true }; } - await this._prisma.taskRun.update({ + // runops-legacy-ok: finalize (cancel) a historical V1 run (legacy-resident) + await runOpsLegacyPrisma.taskRun.update({ where: { id: taskRun.id }, data: { status: "CANCELED", From 618d497b6344fe1763c49e40313f50f2b842936c Mon Sep 17 00:00:00 2001 From: Daniel Sutton Date: Fri, 17 Jul 2026 13:23:00 +0100 Subject: [PATCH 2/4] ci(webapp): wire the run-ops legacy guard into CI and add oxlint fences Run apps/webapp/scripts/runOpsLegacyGuard.ts --check as its own PR job so new code that reaches a run-graph table through the control-plane client (instead of the RunStore) fails the build. Add fast in-editor oxlint rules (trigger-runops plugin) covering the direct-call and read-through-slot cases, scoped to apps/webapp/app. Regenerate the guard baseline, which had drifted stale. --- .github/workflows/pr_checks.yml | 7 + .github/workflows/runops-guard.yml | 38 +++++ .oxlintrc.json | 23 ++- .../v3/runOpsMigration/track1-baseline.json | 71 +++++--- oxlint-plugins/runops-residency.mjs | 156 ++++++++++++++++++ 5 files changed, 267 insertions(+), 28 deletions(-) create mode 100644 .github/workflows/runops-guard.yml create mode 100644 oxlint-plugins/runops-residency.mjs diff --git a/.github/workflows/pr_checks.yml b/.github/workflows/pr_checks.yml index 2629ff8243..b0fbd6ac04 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 0000000000..e496db2a93 --- /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 e32d206dc3..d9b4cb2171 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 ed801775c1..647c5bbde3 100644 --- a/apps/webapp/app/v3/runOpsMigration/track1-baseline.json +++ b/apps/webapp/app/v3/runOpsMigration/track1-baseline.json @@ -68,45 +68,64 @@ "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": 2 }, - "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/presenters/v3/ApiBatchResultsPresenter.server.ts", + "line": 195, + "model": "TaskRun", + "delegate": "taskRun", + "callKind": "read", + "detector": "i", + "snippet": "const legacyRows = (await legacyReplica.taskRun.findMany({" + } + ], + "legacyAnnotations": [ + { + "file": "apps/webapp/app/v3/services/cancelTaskRun.server.ts", + "line": 48, + "reason": "bulk-action tag on a historical V1 run (legacy-resident)", + "receiver": "runOpsLegacyPrisma" }, { - "file": "apps/webapp/app/v3/services/executeTasksWaitingForDeploy.ts", - "line": 92, - "reason": "post ownerEngine()!==\"NEW\" filter", + "file": "apps/webapp/app/v3/services/cancelTaskRun.server.ts", + "line": 57, + "reason": "finalize (cancel) a historical V1 run (legacy-resident)", "receiver": "runOpsLegacyPrisma" } ] diff --git a/oxlint-plugins/runops-residency.mjs b/oxlint-plugins/runops-residency.mjs new file mode 100644 index 0000000000..177c9828f6 --- /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; From f98b350f4bec6138be429c2db7c8430de407e87f Mon Sep 17 00:00:00 2001 From: Daniel Sutton Date: Fri, 17 Jul 2026 14:08:10 +0100 Subject: [PATCH 3/4] fix(webapp): make the run-ops legacy guard classify clients independent of build state The guard resolved the Prisma client packages through their built dist/, which the CI job does not build (install + generate only). That left every client type unresolved in CI, so the guard classified nothing as control-plane and reported 0 violations - blind, and would rubber-stamp a real leak. Pin resolution to the checked-in sources, match declaration files against absolute generated-client dirs, and add a preflight + classification anchors that exit non-zero rather than pass blind. --- apps/webapp/scripts/runOpsLegacyGuard.ts | 146 ++++++++++++++++++++++- 1 file changed, 145 insertions(+), 1 deletion(-) diff --git a/apps/webapp/scripts/runOpsLegacyGuard.ts b/apps/webapp/scripts/runOpsLegacyGuard.ts index a8accc3871..d56d203551 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(", ")}` From 3835389397df36d2b7c0569467401ed75f6a1a4e Mon Sep 17 00:00:00 2001 From: Daniel Sutton Date: Fri, 17 Jul 2026 15:32:40 +0100 Subject: [PATCH 4/4] refactor(webapp): use the branded run-ops legacy handle in cancelTaskRun Swap cancelTaskRun's V1 finalize writes from runOpsLegacyPrisma (control-plane typed, needed a runops-legacy-ok annotation) to the branded runOpsLegacyPrismaClient, which the guard classifies as run-ops. Same legacy writer at runtime; drops the two annotations and their baseline entries. Matches the existing legacyWriter usage. --- .../app/v3/runOpsMigration/track1-baseline.json | 17 ++--------------- .../app/v3/services/cancelTaskRun.server.ts | 8 +++----- 2 files changed, 5 insertions(+), 20 deletions(-) diff --git a/apps/webapp/app/v3/runOpsMigration/track1-baseline.json b/apps/webapp/app/v3/runOpsMigration/track1-baseline.json index 647c5bbde3..9127f2d48e 100644 --- a/apps/webapp/app/v3/runOpsMigration/track1-baseline.json +++ b/apps/webapp/app/v3/runOpsMigration/track1-baseline.json @@ -75,7 +75,7 @@ "write": 0, "read": 4, "files": 1, - "legacyAnnotations": 2 + "legacyAnnotations": 0 }, "violations": [ { @@ -115,18 +115,5 @@ "snippet": "const legacyRows = (await legacyReplica.taskRun.findMany({" } ], - "legacyAnnotations": [ - { - "file": "apps/webapp/app/v3/services/cancelTaskRun.server.ts", - "line": 48, - "reason": "bulk-action tag on a historical V1 run (legacy-resident)", - "receiver": "runOpsLegacyPrisma" - }, - { - "file": "apps/webapp/app/v3/services/cancelTaskRun.server.ts", - "line": 57, - "reason": "finalize (cancel) a historical V1 run (legacy-resident)", - "receiver": "runOpsLegacyPrisma" - } - ] + "legacyAnnotations": [] } diff --git a/apps/webapp/app/v3/services/cancelTaskRun.server.ts b/apps/webapp/app/v3/services/cancelTaskRun.server.ts index dcf23010ad..543e303e36 100644 --- a/apps/webapp/app/v3/services/cancelTaskRun.server.ts +++ b/apps/webapp/app/v3/services/cancelTaskRun.server.ts @@ -1,5 +1,5 @@ import { RunEngineVersion, type TaskRun } from "@trigger.dev/database"; -import { runOpsLegacyPrisma } from "~/db.server"; +import { runOpsLegacyPrismaClient } from "~/db.server"; import { engine } from "../runEngine.server"; import { isCancellableRunStatus } from "../taskStatus"; import { BaseService } from "./baseService.server"; @@ -44,8 +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) { - // runops-legacy-ok: bulk-action tag on a historical V1 run (legacy-resident) - await runOpsLegacyPrisma.taskRun.update({ + await runOpsLegacyPrismaClient.taskRun.update({ where: { id: taskRun.id }, data: { bulkActionGroupIds: { push: options.bulkActionId } }, }); @@ -53,8 +52,7 @@ export class CancelTaskRunService extends BaseService { return { id: taskRun.id, alreadyFinished: true }; } - // runops-legacy-ok: finalize (cancel) a historical V1 run (legacy-resident) - await runOpsLegacyPrisma.taskRun.update({ + await runOpsLegacyPrismaClient.taskRun.update({ where: { id: taskRun.id }, data: { status: "CANCELED",