Skip to content

Commit 618d497

Browse files
committed
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.
1 parent f9960a8 commit 618d497

5 files changed

Lines changed: 267 additions & 28 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: 45 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -68,45 +68,64 @@
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": 2
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"
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({"
116+
}
117+
],
118+
"legacyAnnotations": [
119+
{
120+
"file": "apps/webapp/app/v3/services/cancelTaskRun.server.ts",
121+
"line": 48,
122+
"reason": "bulk-action tag on a historical V1 run (legacy-resident)",
123+
"receiver": "runOpsLegacyPrisma"
105124
},
106125
{
107-
"file": "apps/webapp/app/v3/services/executeTasksWaitingForDeploy.ts",
108-
"line": 92,
109-
"reason": "post ownerEngine()!==\"NEW\" filter",
126+
"file": "apps/webapp/app/v3/services/cancelTaskRun.server.ts",
127+
"line": 57,
128+
"reason": "finalize (cancel) a historical V1 run (legacy-resident)",
110129
"receiver": "runOpsLegacyPrisma"
111130
}
112131
]
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
/**
2+
* oxlint plugin: trigger-runops — fast in-editor fences for the run-ops DB split.
3+
* Name-based ports of two detectors from the authoritative type-aware guard
4+
* (apps/webapp/scripts/runOpsLegacyGuard.ts, run as `--check` in CI). Scoped to
5+
* apps/webapp/app via .oxlintrc.json overrides, matching the guard's scope.
6+
*/
7+
8+
// The 16 run-graph delegates (camelCased run-ops models). Authoritative source is
9+
// internal-packages/run-ops-database/prisma/schema.prisma, which the tsx guard
10+
// parses; kept as a literal here so a missing schema can never disable linting.
11+
const RUN_GRAPH_DELEGATES = new Set([
12+
"taskRun",
13+
"taskRunExecutionSnapshot",
14+
"taskRunCheckpoint",
15+
"waitpoint",
16+
"taskRunWaitpoint",
17+
"waitpointRunConnection",
18+
"completedWaitpoint",
19+
"waitpointTag",
20+
"taskRunTag",
21+
"taskRunDependency",
22+
"taskRunAttempt",
23+
"batchTaskRun",
24+
"batchTaskRunItem",
25+
"batchTaskRunError",
26+
"checkpoint",
27+
"checkpointRestoreEvent",
28+
]);
29+
30+
// Global control-plane client exports (from ~/db.server).
31+
const CONTROL_PLANE_GLOBALS = new Set(["prisma", "$replica"]);
32+
33+
// Read-through config slots that MUST carry a run-ops client. Kept in sync with
34+
// RUN_OPS_READTHROUGH_SLOTS in scripts/runOpsLegacyGuard.ts. `controlPlaneReplica`
35+
// is intentionally absent — it is meant to be control-plane.
36+
const RUN_OPS_READTHROUGH_SLOTS = new Set([
37+
"newClient",
38+
"newReplica",
39+
"runOpsNew",
40+
"legacyReplica",
41+
"runOpsLegacyReplica",
42+
]);
43+
const CONTROL_PLANE_CLIENT_IDENTIFIERS = new Set(["$replica", "prisma"]);
44+
const CONTROL_PLANE_THIS_FIELDS = new Set(["_replica", "_prisma"]);
45+
46+
// Parentheses are not nodes in oxlint's ESTree, so only type-only wrappers unwrap.
47+
function unwrap(node) {
48+
let current = node;
49+
while (
50+
current &&
51+
(current.type === "TSAsExpression" ||
52+
current.type === "TSSatisfiesExpression" ||
53+
current.type === "TSNonNullExpression")
54+
) {
55+
current = current.expression;
56+
}
57+
return current;
58+
}
59+
60+
function valueIsControlPlaneClient(node) {
61+
const expr = unwrap(node);
62+
if (!expr) return false;
63+
if (expr.type === "Identifier") return CONTROL_PLANE_CLIENT_IDENTIFIERS.has(expr.name);
64+
if (
65+
expr.type === "MemberExpression" &&
66+
!expr.computed &&
67+
expr.object.type === "ThisExpression" &&
68+
expr.property.type === "Identifier"
69+
) {
70+
return CONTROL_PLANE_THIS_FIELDS.has(expr.property.name);
71+
}
72+
return false;
73+
}
74+
75+
function propertyKeyName(property) {
76+
const key = property.key;
77+
if (!key) return undefined;
78+
if (key.type === "Identifier" && !property.computed) return key.name;
79+
if (key.type === "Literal" && typeof key.value === "string") return key.value;
80+
return undefined;
81+
}
82+
83+
/** @type {import("eslint").Rule.RuleModule} */
84+
const noControlPlaneRunGraphAccess = {
85+
meta: {
86+
type: "problem",
87+
docs: {
88+
description:
89+
"Disallow reaching a run-graph table through the global control-plane Prisma client; route through the RunStore.",
90+
},
91+
messages: {
92+
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.',
93+
},
94+
schema: [],
95+
},
96+
create(context) {
97+
return {
98+
MemberExpression(node) {
99+
if (node.computed || node.property.type !== "Identifier") return;
100+
if (!RUN_GRAPH_DELEGATES.has(node.property.name)) return;
101+
if (node.object.type !== "Identifier" || !CONTROL_PLANE_GLOBALS.has(node.object.name)) {
102+
return;
103+
}
104+
context.report({
105+
node,
106+
messageId: "leak",
107+
data: { delegate: node.property.name, client: node.object.name },
108+
});
109+
},
110+
};
111+
},
112+
};
113+
114+
/** @type {import("eslint").Rule.RuleModule} */
115+
const noControlPlaneInRunOpsSlot = {
116+
meta: {
117+
type: "problem",
118+
docs: {
119+
description:
120+
"Disallow assigning a control-plane Prisma client into a run-ops read-through config slot (routes run-ops reads at the wrong database).",
121+
},
122+
messages: {
123+
misroute:
124+
'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.',
125+
},
126+
schema: [],
127+
},
128+
create(context) {
129+
return {
130+
Property(node) {
131+
const slot = propertyKeyName(node);
132+
if (!slot || !RUN_OPS_READTHROUGH_SLOTS.has(slot)) return;
133+
if (node.shorthand) return; // `{ legacyReplica }` binds a same-named local, not a cp export.
134+
if (!valueIsControlPlaneClient(node.value)) return;
135+
const client =
136+
node.value.type === "Identifier"
137+
? node.value.name
138+
: context.sourceCode.getText(unwrap(node.value));
139+
context.report({ node: node.value, messageId: "misroute", data: { slot, client } });
140+
},
141+
};
142+
},
143+
};
144+
145+
/** @type {import("eslint").ESLint.Plugin} */
146+
const plugin = {
147+
// Distinct namespace: oxlint keys plugins by meta.name, so this cannot reuse
148+
// "trigger" without clobbering no-thrown-unawaited-redirect.
149+
meta: { name: "trigger-runops" },
150+
rules: {
151+
"no-control-plane-run-graph-access": noControlPlaneRunGraphAccess,
152+
"no-control-plane-in-runops-slot": noControlPlaneInRunOpsSlot,
153+
},
154+
};
155+
156+
export default plugin;

0 commit comments

Comments
 (0)