Repository navigation
Effect Service Conventions: 6 issues found
Six convention violations were found in the changed Effect service code and its call sites.
Dependency acquisition / runtime boundaries
apps/server/src/serverShutdown.ts— the handoff channel is a module-global mutableSetshared between the CLI andServerSelfUpdate; it should be aContext.Service(tag +make+layer) provided by the CLI alongsideServerConfig.apps/server/src/cloud/serviceLauncherClient.ts—make,isServiceLauncherManaged, andisServiceLauncherTrialdefault to the globalprocessand readprocess.envdirectly instead of the existingHostProcessEnvironmentreference;options.processis a test-only seam rather than a declared requirement.apps/server/src/persistence/Layers/Sqlite.ts—layerConfigre-decodes launcher context from the globalprocess.envviaisServiceLauncherTrial()instead of usingServiceLauncherClient.trial, with weaker validation than the service.apps/server/src/server.ts—ServiceLauncherClient.layeris provided both insidemakeRoutesLayerand to the wholemakeServerLayer, constructing the stateful client (activationDeferred, outcomeRef) twice soServerSelfUpdateuses a different instance than the activation gate.
Error modelling
apps/server/src/cloud/selfUpdate.ts— a user-facing blocked-preflight sentence is carried inPinnedRuntimeInstallError.stepand recovered withstartsWithprefix matching, which derives the caller-visible message from a diagnostic field; a distinct tagged error plusEffect.catchTagsis the expected shape.apps/server/src/cloud/serviceLauncherClient.ts—ServiceLauncherClientErrorstores a free-formreasonstring as its only data across six distinct failures; use separate error classes or a multi-value discriminator with a derivedmessage.
All findings were posted as inline review comments.
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: new and modified TypeScript under apps/server/src (cloud/serviceLauncherClient.ts, cloud/serviceProtocol.ts, cloud/servicePreflight.ts, cloud/selfUpdate.ts, cloud/bootService.ts, cloud/pinnedRuntime.ts, serverShutdown.ts, serverActivation.ts, serverRuntimeStartup.ts, server.ts, persistence/**, orchestration/provider start(activation) signature updates) plus packages/contracts and packages/client-runtime changes.
Not flagged (considered compliant or out of scope):
- Subpath namespace imports (
effect/Effect,effect/Layer, …) are used consistently in the new modules; local service modules are imported as namespaces where the service/tag,make, orlayeris consumed. ServiceLauncherClientandServerSelfUpdatefollow the canonical file order with inline interfaces inContext.Service, exportedmake, and exportedlayer;Foo["Service"]is used for implementation types.start(activation?: Effect.Effect<void>)on the reactor shapes passes a gate value, not a service instance, so it is not fake dependency injection. The pre-existingfs/path/runnerparameters ofensurePinnedRuntimeInstalledwere not introduced by this PR.MigrationManifestMismatchErroruses structured fields with a derived message.runServicePreflightis a synchronous CLI-boundary helper invoked from a hiddenCommandhandler, not an Effect service.
🔍 Agent activity — 1 tool call
💭 I'm reviewing the diff for Effect service convention violations, starting with the new files that were added. Looking at serviceLauncherClient.ts, I can see it's properly importing from the Effect library and defining an error class for the service launcher client. The error class uses
Schema.TaggedErrorClasswith areasonfield as an unstructured string, which seems to violate the convention about capturing stable, serializable domain context instead of free-form messages. However, I notice there's already aServerSelfUpdateErrorin the repo using the samereasonpattern, so this appears to be following an existing convention in the codebase. Still, theServiceLauncherClientErroris using thatreasonfield to represent many different failure scenarios with distinct messages like "The service launcher supplied invalid startup context" and others, which suggests the error handling could be more structured. Now I'm identifying the strongest findings: theserviceLauncherClient.tsmodule acquires dependencies directly from the globalprocessobject rather than through the Effect environment, andSqlite.tscallsisServiceLauncherTrial()inside a layer, which is another hidden global read instead of using the Effect environment properly. The repo already hasHostProcessEnvironment,HostProcessPlatform, and related utilities from@t3tools/shared/hostProcessspecifically for this purpose, so the regression is clear —selfUpdate.tspreviously used these environment-based references but now readsprocess.envdirectly through the client's default process. I'm also looking atserverShutdown.ts, which has a module-level mutablelistenersset acting as a process-global channel withrequestandawaitRequestas module functions rather than a proper service, which is another violation of the convention against hiding dependencies in module globals. TheServiceLauncherClientlayer inserviceLauncherClient.tslooks fine with its standardLayer.effectpattern, andserverRuntimeStartup.tswith its options-basedmakeandlayerWithOptionsis acceptable. Now I'm checkingbootService.ts, which is adding direct imports ofnode:fs/promisesandnode:cryptowith awriteDurablyfunction using Effect. tryPromise andprocess.pid— it has a diagnostics suppression comment. There's an existingwriteFileStringAtomicallyutility inatomicWrite.tsthat was previously used but is now removed. The issue here is thatwriteDurablyinsidemakeis calling Node fs directly while also acquiring thefsFileSystem service, mixing runtime-backed APIs with imperative ones. Usingprocess.piddirectly also bypasses proper dependency acquisition, which violates the convention against hiding dependencies in module globals or Layer.succeed implementations that call runtime-backed APIs. The file has an explicit diagnostics-off comment, suggesting the launcher needs to be standalone, though bootService isn't actually standalone. They need fsync which the Effect FileSystem service might not provide, so I probably shouldn't fail on this but could flag it as a concern. Looking at the error handling,PinnedRuntimeInstallErroris being reused withstepstrings where the mapping checkserror.step.startsWith("This version includes")— essentially using thestepfield to carry a full user-facing reason and then string-sniffing to decide the message. This violates the convention that semantically distinct failures should have separate error classes rather than using a discriminator field likestepto drive control flow and message selection.MigrationManifestMismatchErrorlooks fine with its structured fields and derived message. TheservicePreflight.tsmodule uses pure functions and callsnode:sqlitesynchronously outside Effect at the CLI boundary, which is probably acceptable even though it's technically not a service. ButserviceLauncherClient.tshas manualJSON.parsewith hand-rolled validation instead of usingSchema, and while the PR has some@effect-diagnostics-next-line preferSchemaOverJson:offannotations, the decoder still needs attention. The decoders for parsing the service state are duplicated across three files when they should live in a single canonical location likeserviceProtocol.ts, which already defines the types — that's a moderate finding worth flagging against the conventions around keeping schemas and their implementations together. The tricky part is thatserviceLauncher.tsis a standalone Node bundle that can't pull in Effect runtime dependencies, so while it imports types fromserviceProtocol.ts, a Schema-based decoder would bloat it unnecessarily. The real duplication I should focus on is betweenbootService'sreadStateandserviceLauncherClient'sdecodeUpdate— those could be shared, though it's a lighter concern. InselfUpdate.ts, themakefunction no longer needs host options passed in; it now pullsServiceLauncherClient,ProcessRunner, and other dependencies from the environment, which is cleaner. But I'm noticingresolveServresolveServerSelfUpdateCapabilityis callingisServiceLauncherManaged()to read the global process, andServerEnvironment.tsis where this gets used — it's an Effect function that succeeds but hides a global read inside. Same pattern I flagged before. Then inSqlite.ts, thelayerConfigis usingisServiceLauncherTrial()— another global read baked into a layer definition, when theServiceLauncherClientservice already exposestrialas a proper dependency. This is a real violation: the persistence layer should pull the trial flag from the service or config instead of re-readingprocess.envdirectly. There's a potential layering cycle issue sinceServiceLauncherClientis provided at themakeServerLayerlevel and persistence sits deeper, but it's still worth flagging because it duplicates the launcher context decoding and can diverge from the client's stricter validation — the client fails on invalid context while this just returns false. Looking atserverActivation.tsandProviderCommandReactor, both accept an optionalactivationEffect parameter as a callback or gate, which is fine since it's a deliberate strategy pattern rather than a service dependency. But when I checkserviceLauncherClient.make, it accepts test seams likeprocessandcurrentVersionin its options, which is similar to the oldhostpattern. The repo already hasHostProcessExecutablePathand other dedicated Context services for this, soServiceLauncherProcessshould probably be a service too instead of a plain object with imperative methods that defaults to the globalprocess. The real issue is that production defaults are reading globals directly, hiding the runtime dependency in a module default rather than acquiring it from the environment. I need to check the hostProcess package to see what conventions are already established, and verify thepackageJsonimport for the currentVersion default. I'm noticing the newserviceLauncherClient.tsis accessingprocess.envdirectly throughoptions?.process ?? processinstead of going through theHostProcessEnvironmentpattern that the rest of the codebase uses viaContext.Reference. This creates a hidden dependency in the module defaults rather than properly exposing it through the Effect environment. The helper functions likeisServiceLauncherManaged()andisServiceLauncherTrial()are also plain synchronous functions that read from globals directly. I'm looking at theServiceLauncherClientErrorclass, which usesSchema.TaggedErrorClasswith just areasonattribute and relies on the base class's default message implementation rather than overriding it. The pattern here aligns with the convention of deriving the message from structured attributes instead of storing raw unstructured text. However, I notice this error class is handling about six distinct failure modes—invalid context, version mismatch, IPC unavailable, not managed, send failure, disconnect, and launcher rejection—all mapped through a singlereasonfield that becomes caller-visible. The convention suggests that distinct caller-visible messages should map to distinct error classes or at least have structural discriminators. That said, there's precedent in the codebase withServerSelfUpdateError, which also uses areasonfield for caller-visible RPC messages, so this might be a deliberate stylistic choice. It's technically a violation of the convention's guidance on distinct failures, but it would require significant refactoring to address properly. I'll flag it as a moderate-confidence finding rather than a high-confidence one. Now let me check the exact lines in selfUpdate.ts where thestep.startsWithlogic appears. I'm looking at a specific code pattern inselfUpdate.tswhere an error class is being misused to pass user-facing messages through prefix-matching logic, which violates the principle of keeping semantically distinct failures in separate error classes rather than using a single class with discriminators to drive different control flow paths. The fix would be to create a dedicated error type likeServicePreflightBlockedErrorwith explicitreasonandversionfields, then catch it usingEffect.catchTagsfor cleaner separation of concerns. I'm also noticing a hidden global process dependency issue inserviceLauncherClient.tswhereisServiceLauncherManaged()andisServiceLauncherTrial()are called from production Effect code without proper dependency injection — the code usesoptions?.process ?? processas a fake-DI seam, and while the IPC handle itself genuinely needs the Node process, the environment variables should be sourced fromHostProcessEnvironmentContext instead. The real problem is thatisServiceLauncherTrial()in the Sqlite layer duplicates whatServiceLauncherClient.trialalready provides, but I need to verify whether the persistence layer can actually access theServiceLauncherClientthat's provided at the top level ofmakeServerLayer— if it's plumbed through the layer composition correctly, the persistence layer could receive the trial flag directly rather than making a separate global read. ForserverShutdown.ts, there's a clear architectural issue: it uses a module-global mutableSetas a handoff channel between the CLI and a service, which violates the principle of not hiding dependencies in module globals — this should be a properContext.Servicewith a layer instead, and it's also untestable because it leaks state across tests in the same process. InbootService.ts,writeDurablyis doing filesystem operations directly vianode:fs/promisesandprocess.pidinside a service that already hasFileSystemacquired, which means it's bypassing the acquired service dependency and making the service untestable — there's even an existingwriteFileStringAtomicallyutility inatomicWrite.tsthat should be used instead of hand-rolling durable writes with raw Node APIs. TheensurePinnedRuntimeInstalledfunction already takesfs,path, andrunneras parameters from before this PR, so I won't flag that as a concern—the PR only adds thevalidateparameter to the existing signature. I'm also checking what the error message forPinnedRuntimeInstallErrorcontains. The message template isPinned runtime install failed while ${step}.Whenpreflight.reasongets passed as thestepparameter, it produces something like "Pinned runtime install failed while This version includes a database update and cannot be installed remotely. Runnpx t3@x service update..." which is clearly nonsensical without string parsing. That's a definite violation. Now I need to get the accurate line numbers from the file heads for server.ts, Sqlite.ts, and serverShutdown.ts so I can provide the correct line positions for the GitHub PR review comments. I'm checking whether there's an existing pattern in the codebase for managing channels like this—maybe through a Context.Service or PubSub approach—and verifying that ServiceLauncherClient can be accessed from the CLI and server layers to handle the shutdown channel. The CLI races the shutdown request against the server runtime, and the self-update logic triggers the shutdown, so I need to structure this using Effect's patterns to make it all work together cleanly. The CLI can create the channel directly and pass it as a service alongsideServerConfig, which aligns with what the server.ts comment suggests. I'm identifying the key findings to document as inline comments, starting with the module-level mutable listener Set in serverShutdown.ts that acts as a hidden runtime dependency between the CLI andServerSelfUpdate. For the second finding, I need to refactor howPinnedRuntimeInstallError.stephandles user-facing blocked reasons—instead of using string prefix matching, I should create a dedicated error class likeServicePreflightBlockedErrorwith explicitversionandreasonfields, then useEffect.catchTagsfor proper error handling. The third finding spans two locations:serviceLauncherClient.tsreads the globalprocessand environment variables directly whenHostProcessEnvironmentalready exists as a reference, and thenSqlite.tsline 80 callsisServiceLauncherTrial()as a global read inside a layer even thoughServiceLauncherClient.trialis available—I'll post separate comments on each file to address these issues. For the fourth point,ServicePreflightBlockedErroronly carries a free-formreasonstring across six different failure modes, which violates the convention requiring structured error attributes with derived messages. I should flag this as a lower-priority inline comment, suggesting either a structural discriminator likeoperationorkindplus a derived message, or separate error classes entirely. The fifth issue is inbootService.tswherewriteDurablyusesnode:fs/promisesdirectly viaEffect.tryPromise, bypassing the acquiredFileSystemservice—this hides a dependency and makes the service's writes untestable, so I'll flag it as violating the convention against hiding runtime-backed APIs in module globals or implementations. Actually, looking closer at Effect'sFileSystemAPI, theFileinterface does expose asyncmethod for fsync operations, so the durability requirement could theoretically be met through the service. Directory fsync might be trickier, but I'll mention this as a non-blocking note rather than a hard violation. Scanning the dependency acquisition rules — the instructions emphasize reporting concrete violations to avoid false positives.writeDurablydoesn't appear in the patterns they're looking for (service parameters,Layer.succeed,ManagedRuntime.make), so I'll skip flagging it inline. Now checking the server.ts area for other issues — I'm noticingServiceLauncherClient.layermight be provided twice in the route layer setup. So the two instances ofServiceLauncherClienthave separate activation deferreds and trial states — the routes instance's activation is never actually awaited by anything, which meansServerSelfUpdateoperates independently from the server runtime startup that uses the other instance. The real issue is thatmakeRoutesLayernests aLayer.providethat shadows theServiceLauncherClient.layeralready provided at the outer level inmakeServerLayer, creating a duplicate stateful instance with its own independent Deferred and Ref state. This violates the principle of composing a resource once at the application level and providing it to all consumers rather than duplicating it across feature layers. I'm seeing that theLayer.providecall forServiceLauncherClient.layeris nested insidemakeRoutesLayerat line 419, while a separate provision happens at themakeServerLayerlevel around line 629. Since layer memoization is scoped to eachLayer.providecomposition, the nested provision creates its own isolated instance—meaningServerSelfUpdateends up with a different client than the one controlling activation, complete with separate Deferred/Ref state and its own IPC listener tracking. The duplication itself might not cause a hang sinceServerSelfUpdatedoesn't await activation, but it's still a layering violation—the same stateful service is being instantiated twice. I should also check ifmakeRoutesLayeris exported and used elsewhere, since if it's meant to be self-contained in tests or desktop code, the nested provide might be intentional. I'm noticing thatmakeRoutesLayerself-providesServiceLauncherClientfor standalone use in tests, but this creates a duplicate instance in production sincemakeServerLayeralso buildsroutesLayerfrommakeRoutesLayerwhile the outer layer provides the same dependency. The question is whether this actually causes problems—the routes-side instance doesn't register listeners during construction since those are per-exchange. The real issue is thatmakeperforms validation which is wasteful to duplicate, and there's potential state divergence withlatestOutcomeandprepareTrialrefs thatServerSelfUpdatedoesn't actually read. It's a latent hazard rather than a current bug, but worth flagging as a comment since it violates the convention of not creating multiple managed runtimes for the same owned resource. The fix would be straightforward: remove the nested provide frommakeRoutesLayerand handle it in the test instead. Now I'm settling on my final set of inline comments. I've identified six issues across the codebase: the module-global channel inserverShutdown.ts, step-prefix sniffing inselfUpdate.ts, using globalprocessand env instead of proper dependency injection inserviceLauncherClient.ts, the relatedisServiceLauncherTrial()global read inSqlite.ts, an error that only carries free-formreasontext, and the double provide of the statefulServiceLauncherClient. That's a lot, but they're all grounded concerns. I'll keep the first four as strong flags and phrase the remaining two as suggestions to maintain signal clarity. Now I need to verify that the line numbers I'm using actually appear in the diff so the comments will attach properly. For selfUpdate.ts, the mapError block is newly added around lines 160-167, so I'll use start_line 152 and line 166 to cover the relevant section. For serviceLauncherClient.ts, I'm looking at line 210 where the host variable is assigned, spanning lines 206-213. And I still need to check the Sqlite.ts change at line 80. For #2, the fix spans multiple files since it needs a new error class in either servicePreflight or pinnedRuntime with catchTags, so I'll describe the approach rather than show a diff. For #1, I'd suggest defining a Context.Service across multiple files without a diff. For #3, the fix requires acquiring HostProcessEnvironment in the make function—I can sketch that briefly. For #4, I could note threading the trial flag from ServiceLauncherClient. Now I'm drafting the comments, starting with #3: the issue is that make defaults to the global process for both the IPC handle and env lookup, reading the launcher context outside the Effect environment, but the repo already has HostProcessEnvironment modeled in hostProcess.ts that we could use instead. For the IPC handle override in tests, I'd suggest exposing it as a Context.Reference rather than an options.process default so the dependency is explicit in the layer's requirements. I should also note that isServiceLauncherManaged and isServiceLauncherTrial are sync globals called from selfUpdate.resolveServerSelfUpdateCapability and Sqlite.layerConfig, and resolveServerSelfUpdateCapability itself is an Effect.fn that reads these globals inside, so that's worth mentioning in the comment. Since findings exist, the check state should be set to failure per the convention violation rule.
1. modify_pr — Reviewed the new service modules and their call sites against the Effect service conventions. Six findings, mostly around dependency acquisition (module globals / global process instead of the Effect environment) and error modelling (a step string used to drive caller-visible messages). Details inline.
Agent Credits: 224 credits