Skip to content

Address self-update review feedback

MacroscopeApp / Macroscope - Effect Service Conventions failed Jul 22, 2026 in 3m 11s

Effect Service Conventions: 1 issue found

One convention violation found in the changed scope.

  • apps/server/src/cloud/selfUpdate.ts (line 229): The ServerSelfUpdateError wrapper's caller-visible message is derived from cause.message. failWith(error.message, error) copies the structured PinnedRuntimeInstallError's derived message string into reason, and ServerSelfUpdateError.message renders Server update failed: ${reason}. The convention requires deriving a wrapper's message from stable structural attributes and keeping the upstream error only as cause, rather than erasing a structured error into the message-bearing field. The full error is already preserved as cause, so reason should 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.Service interface, Foo["Service"] usage, real make/layer exports, and environment-based dependency acquisition (yield* ServerConfig.ServerConfig, FileSystem, Path, ProcessRunner) all follow the conventions. The host option and the fs/path/runner params threaded into ensurePinnedRuntimeInstalled are legitimate test seams / helper threading from real service constructors, not fake DI.
  • PinnedRuntimeInstallError uses a multi-value step discriminator with a generic derived message, an accepted single-service-error model; bootService re-mapping on step is a translation-boundary detail.
  • TaggedErrorClass usage, Effect.catch over fully-handled channels, and cause preservation 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 PinnedRuntimeInstallError uses Schema.TaggedErrorClass with 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 the step field 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 check step.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/contracts and @t3tools/shared/hostProcess are 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 a make function, so it's technically a service module, but selfUpdate only uses pure helper functions and constants from it like renderBootServiceUnit and quoteSystemdValue. The convention allows named imports for pure helpers, so this is probably acceptable. Similarly, ensurePinnedRuntimeInstalled from 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. The ProcessRunner.ProcessRunner["Service"] parameter does match the flagged pattern, but since this is a shared helper rather than a service make function, I'm not sure the convention is meant to catch this case. Now looking at the ServerSelfUpdateError class — it extends a tagged error with a reason field that's a trimmed non-empty string, and an optional cause field for defects. The error message formats the reason into a readable string. The issue is that reason is 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 via updateFailureMessage(), 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 since ServerSelfUpdateError is 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 checks error.step.startsWith("installing"). The splitting rule applies when a discriminator drives control flow, which step does here, so PinnedRuntimeInstallError could 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 how Effect.catch is being used versus catchTags in places like selfUpdate.ts, where Effect.catch handles the entire error channel for a single error type. In GitManager.ts there's a similar pattern where Effect.catch intentionally handles any lookup failure and provides a fallback. That's fine. But then there's this failWith helper that just wraps error construction—it's only creating a new ServerSelfUpdateError with 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. failWith is 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. failWith does 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. But failWith is being used with mapError to extract error.message from a PinnedRuntimeInstallError and pass it as the reason while using the error itself as the cause — that's exactly the anti-pattern of deriving the message from cause.message. Combined with the fact that failWith performs no real normalization and is used inconsistently across the codebase, this is a clear violation of the guideline. The reason field is being populated directly from cause.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. PinnedRuntimeInstallError isn't part of the declared RPC channel, so wrapping is necessary, but copying error.message into reason stringifies 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. The reason field 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 the error.message copy, 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 the PinnedRuntimeInstallError with its step field as a discriminator and branches on whether the step starts with "installing" to create either a BootServiceCommandError or BootServiceInstallError, effectively splitting the generic error into two distinct types based on the step value. The real issue I'm spotting is in selfUpdate.ts, where failWith is 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 the ServerSelfUpdateError design, I see it uses a single error class with a free-form reason field 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 using Schema.TaggedErrorClass with 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 with mapError((error) => failWith(error.message, error)). When a structured PinnedRuntimeInstallError gets 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 the reason field is directly set to error.message, which violates that principle. The fix would be to construct the reason from the wrapper's own structural properties instead. Looking at the failWith helper itself, it's just a thin constructor that creates a ServerSelfUpdateError with 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 whether ServerSelfUpdateError itself is the deeper problem—the convention requires the wrapper's message to come from stable structural attributes, but reason is 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 that reasonis being set toerror.message, which stringifies the structured PinnedRuntimeInstallErrorinto 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 the failWith` 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