Repository navigation
Effect Service Conventions: 1 issue found
One convention violation found in the changed scope.
apps/server/src/cloud/selfUpdate.ts(line 229): TheServerSelfUpdateErrorwrapper's caller-visible message is derived fromcause.message.failWith(error.message, error)copies the structuredPinnedRuntimeInstallError's derived message string intoreason, andServerSelfUpdateError.messagerendersServer update failed: ${reason}. The convention requires deriving a wrapper's message from stable structural attributes and keeping the upstream error only ascause, rather than erasing a structured error into the message-bearing field. The full error is already preserved ascause, soreasonshould be a stable phrase.
Details
Note
Your check run agent prompt is: .macroscope/check-run-agents/effect-service-conventions.md
More information about how Check Run Agents work can be found in our Help Center.
Scope: reviewed the new Effect service work — cloud/selfUpdate.ts, cloud/pinnedRuntime.ts, and the packages/contracts error/schema additions — plus directly affected call sites (bootService.ts, server.ts, ws.ts).
Other areas checked and considered acceptable:
- Namespace imports, service definition order, inline
Context.Serviceinterface,Foo["Service"]usage, realmake/layerexports, and environment-based dependency acquisition (yield* ServerConfig.ServerConfig,FileSystem,Path,ProcessRunner) all follow the conventions. Thehostoption and thefs/path/runnerparams threaded intoensurePinnedRuntimeInstalledare legitimate test seams / helper threading from real service constructors, not fake DI. PinnedRuntimeInstallErroruses a multi-valuestepdiscriminator with a generic derived message, an accepted single-service-error model; bootService re-mapping onstepis a translation-boundary detail.TaggedErrorClassusage,Effect.catchover fully-handled channels, andcausepreservation elsewhere are consistent with the conventions.
The single flagged line couples the wrapper's message to the underlying error's message string; a precise inline comment with a suggested fix was posted.
🔍 Agent activity — 1 tool call
💭 I'm looking at the diff to check how the new modules align with Effect service conventions. The key files to examine are the new pinnedRuntime and selfUpdate modules, along with the contract definitions for errors and environment configuration. I'm starting by reviewing how the Effect library is being imported and used in these files. The
PinnedRuntimeInstallErrorusesSchema.TaggedErrorClasswith structured attributes like step, exitCode, and stderr/stdout lengths, deriving the message from these attributes. This follows the pattern of using a multi-value operation discriminator. However, I'm noticing that thestepfield isn't just for error categorization — it's actually being used to drive control flow in the calling code, where different step values trigger different error handling paths. This violates the convention against encoding the same distinction twice with both a specific error tag and a discriminator that controls behavior. The checkstep.startsWith("installing")in bootService.ts is a borderline case — it's using the discriminator to choose between two different error classes, which suggests these failures are semantically distinct and should probably be split into separate error types rather than relying on a string prefix check. Let me look at other examples to see if there are clearer violations. Now I'm reviewing the imports in selfUpdate.ts. The named imports from@t3tools/contractsand@t3tools/shared/hostProcessare allowed since they're from whole packages or context configuration. The namespace imports from Node and Effect are good. But I need to check whether bootService.ts is a service module — if it is, importing specific functions from it might violate the service boundary convention. Looking at bootService.ts, it does have amakefunction, so it's technically a service module, but selfUpdate only uses pure helper functions and constants from it likerenderBootServiceUnitandquoteSystemdValue. The convention allows named imports for pure helpers, so this is probably acceptable. Similarly,ensurePinnedRuntimeInstalledfrom pinnedRuntime.ts appears to be a pure helper function. Now I'm looking at how the ServerSelfUpdate service itself is structured — it follows the standard pattern with the Context.Service definition, the make function that sets up the effect, and the layer that provides it with ProcessRunner as a dependency. The ordering looks right: imports, docs, constants, interfaces, helper functions, then the service tag, make, and layer. The update method is properly typed using the inferred interface from the service. For dependency acquisition, the make function yields from ServerConfig, FileSystem, Path, ProcessRunner, and HostProcessEnvironment — all acquired from the environment as expected. The host option acts as a test seam with imperative callbacks for things like spawnDetached and exitProcess, which is a deliberate callback strategy. The ensurePinnedRuntimeInstalled helper takes fs, path, and runner as explicit parameters rather than using service injection, which is fine for a utility function. not a service constructor, so the convention might not apply as strictly. The callers already pull these dependencies from the environment and pass them through, which is legitimate dependency threading rather than hidden globals. TheProcessRunner.ProcessRunner["Service"]parameter does match the flagged pattern, but since this is a shared helper rather than a servicemakefunction, I'm not sure the convention is meant to catch this case. Now looking at theServerSelfUpdateErrorclass — it extends a tagged error with areasonfield that's a trimmed non-empty string, and an optionalcausefield for defects. The error message formats the reason into a readable string. The issue is thatreasonis essentially an unstructured message, which violates the convention of deriving messages from structured attributes rather than storing raw text. However, since the reason is exposed to callers through the UI viaupdateFailureMessage(), it's actually behavior-defining — the service distinguishes between many different failure scenarios like invalid versions, desktop-managed conflicts, installation failures, and so on. So the unstructured message here is intentional and necessary for the caller-visible behavior. The convention suggests splitting semantically distinct failures into separate error classes when a reason field drives the user-facing message, but in this case the reason itself is the message — it's not selecting among predefined options. This is the anti-pattern the guidance warns against, and sinceServerSelfUpdateErroris new in this PR, it's worth reconsidering whether each distinct failure type should be its own error class rather than using a free-form reason string. That said, the call sites use rich, descriptive sentences for the reason, and modeling every failure as a separate class would be heavy-handed. This feels like a judgment call rather than a clear violation. Let me shift focus to the more obvious issues, like the control-flow branching in bootService.ts that checkserror.step.startsWith("installing"). The splitting rule applies when a discriminator drives control flow, whichstepdoes here, soPinnedRuntimeInstallErrorcould reasonably be split into separate classes for install-command failures versus filesystem/setup errors. But I should look at the actual code patterns more carefully—let me check howEffect.catchis being used versuscatchTagsin places like selfUpdate.ts, whereEffect.catchhandles the entire error channel for a single error type. In GitManager.ts there's a similar pattern whereEffect.catchintentionally handles any lookup failure and provides a fallback. That's fine. But then there's thisfailWithhelper that just wraps error construction—it's only creating a newServerSelfUpdateErrorwith the provided arguments, which violates the convention that you shouldn't add a helper whose sole purpose is(...args) => new SomeError({ ...args }). The error should be constructed directly at the failure boundary instead.failWithis used about 15 times across the module, which motivated creating the helper in the first place. The convention is explicit though: don't add helpers that only construct errors, and errors should be constructed at failure boundaries to keep attributes and cause visible.failWithdoes qualify as a violation here since it's just a wrapper around the constructor with a trivial conditional. Given that this is introduced in the pull request's scope, I think it's worth flagging as a concrete violation of the convention. The convention text clarifies that the wrapper's message should derive only from stable structural attributes, never from the cause. ButfailWithis being used withmapErrorto extracterror.messagefrom aPinnedRuntimeInstallErrorand pass it as the reason while using the error itself as the cause — that's exactly the anti-pattern of deriving the message fromcause.message. Combined with the fact thatfailWithperforms no real normalization and is used inconsistently across the codebase, this is a clear violation of the guideline. Thereasonfield is being populated directly fromcause.message, which means the wrapper's message is constructed from the cause's message string—exactly what the convention forbids. This is a clear violation of the rule against deriving wrapper messages from cause attributes, even when those attributes are technically structural fields.PinnedRuntimeInstallErrorisn't part of the declared RPC channel, so wrapping is necessary, but copyingerror.messageintoreasonstringifies a structured error into the wrapper's message field, which violates the convention of deriving messages from structural attributes rather than upstream error strings. This is a defensible finding, though somewhat nuanced—I'm trying to gauge how confident I should be in calling this out. Thereasonfield itself is trickier since many of the validation errors are legitimately pure domain errors with distinct messages, and using one error class with free-form reasons goes against the "structured attributes" principle, but the convention also warns against over-splitting. The clearer violation is theerror.messagecopy, so I'll focus there. I should verify this by looking at the actual files rather than just the diff to confirm the pattern and check the boot service as well. Let me verify the actual implementations by checking the bootService.ts file and confirming how the error mapping works, particularly around the PinnedRuntimeInstallError step handling. I need to adjust the URL formatting. I'm noticing the URL has a formatting issue with a newline, so let me simplify it by removing the tree reference and just using the base URL. I need to clean up the URL parameter formatting to remove that extra newline. I see how the bootService mapping works now—it takes thePinnedRuntimeInstallErrorwith itsstepfield as a discriminator and branches on whether the step starts with "installing" to create either aBootServiceCommandErrororBootServiceInstallError, effectively splitting the generic error into two distinct types based on the step value. The real issue I'm spotting is in selfUpdate.ts, wherefailWithis deriving the wrapper's reason and message directly from the cause's message. Let me check the actual file to get the exact line numbers. Looking at theServerSelfUpdateErrordesign, I see it uses a single error class with a free-formreasonfield that becomes the caller-visible message shown in the UI. This approach stores an unstructured message as the primary data, which conflicts with the convention of usingSchema.TaggedErrorClasswith structured attributes and deriving the message from those attributes instead. The diversity of failure modes and the risk of over-splitting into many error classes creates genuine tension here, so this feels like a gray area rather than a clear-cut violation. But there's a more concrete finding emerging: the pattern of mapping errors withmapError((error) => failWith(error.message, error)). When a structuredPinnedRuntimeInstallErrorgets wrapped, its rich attributes like step, exitCode, and log lengths are discarded in favor of just the message string. The convention is explicit that wrapper messages should derive from stable structural attributes, not from the cause's message. Here thereasonfield is directly set toerror.message, which violates that principle. The fix would be to construct the reason from the wrapper's own structural properties instead. Looking at thefailWithhelper itself, it's just a thin constructor that creates aServerSelfUpdateErrorwith minimal logic. The convention discourages such helpers when they don't perform real normalization or pass through domain errors—they should only exist when they add meaningful context or control flow. This one is used throughout the module but doesn't meet that bar. However, the real issue is at line 229:failWith(error.message, error)takes the cause's message and uses it as the wrapper's user-facing reason, which erases the structured error type. That's the concrete violation I should flag. Beyond that, I'm questioning whetherServerSelfUpdateErroritself is the deeper problem—the convention requires the wrapper's message to come from stable structural attributes, butreasonis just a free-form string that could be a hardcoded literal or derived from unstable sources. message. That's the violation — the reason field is being directly populated from the error message at the call site, which means the wrapper's message ultimately derives from cause.message. So line 229 is indeed a clear breach of the rule. I need to decide whether this warrants a failure state or just a neutral flag with an inline comment. The real issue is that line 229 injects the cause's message into the wrapper's user-facing message, which violates the convention of keeping structured errors separate from wrapper messages. I'll add an inline comment there pointing out thatreasonis being set toerror.message, which stringifies the structuredPinnedRuntimeInstallErrorinto the wrapper instead of deriving the message from stable structural attributes and preserving the error ascause. Verifying the exact line and indentation to ensure the fix is properly positioned with the same 6-space indentation, then deciding whether this constitutes a meaningful enough violation to fail the check. I'm debating whether to mention thefailWith` helper in the comment or keep the focus narrow on line 229. The real question is whether that line truly violates the convention—the wrapper message does derive from the cause's message, which the convention explicitly forbids, even though the resulting text is informative and bounded. The test itself expects this behavior, deliberately surfacing the installation step, but a cleaner approach would preserve the structural separation. I'm convinced this is exactly what the check is meant to catch, so I'll flag it and fail the check. Now I need to decide how to report this—I can post an inline comment via the PR review API and then output the final check run JSON as instructed.
1. modify_pr — Effect service review: one error-wrapping convention violation in the new self-update service.
💭 Generating the final JSON output...
Agent Credits: 108 credits