Repository navigation
Effect Service Conventions: 2 issues found
Two findings, both in apps/server/src/cloud/bootService.ts:
- Line 286 — the
ensurePinnedRuntimeInstalledfailure is collapsed into the genericBootServiceInstallError(Could not set up the T3 Code background service.), discarding the structuredstep/exitCode/output-length context that the previous mapping preserved viaBootServiceCommandErrorand thatt3 service installsurfaces to the user. - Lines 145–171 — the new
writeDurablyhelper performs the launcher/state/unit writes with globalnode:fs/promisesand a hand-rolled dirname instead of theFileSystem/Pathservicesmakealready acquires, so the filesystem dependency is no longer represented in the Effect environment andatomicWrite.ts'swriteFileStringAtomicallyis duplicated.
Both 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.
Previously reported items verified as addressed at this head and therefore not repeated: namespace import of serviceLauncherClient in selfUpdate.ts; Effect.catchTags for the single recovered tag in pinnedRuntime.ts; ServiceLauncherClientError given a multi-value operation discriminator; the blocked-preflight case split into PinnedRuntimePreflightBlockedError instead of being encoded in PinnedRuntimeInstallError.step; launcher context acquired via HostProcessEnvironment/ServiceLauncherHostProcess rather than the global process; Sqlite.layerConfig reading trial from the ServiceLauncherClient service; and the launcher client now constructed once in makeServerLayer and shared with both runtimeServicesLive and the routes layer. Test-file findings from the prior run (named make imports at service boundaries, deleted bootService/selfUpdate branch coverage) are unchanged and were not re-posted.
Files examined beyond the diff: apps/server/src/atomicWrite.ts, apps/server/src/cloud/pinnedRuntime.ts, apps/server/src/cloud/selfUpdate.ts, apps/server/src/cloud/serviceLauncherClient.ts, apps/server/src/persistence/Layers/Sqlite.ts, apps/server/src/server.ts, apps/server/src/serverRuntimeStartup.ts, plus effect/unstable/cli import usage across apps/server/src/cli (consistent with existing repository style, not flagged).
🔍 Agent activity — 1 tool call
💭 I'm comparing the current diff against previous findings from earlier commits to see what's been fixed and what new issues might exist. Looking at the file changes, I can see that
selfUpdate.tsnow properly imports the service launcher client, which addresses an earlier concern. I'm checking through the rest of the modified files to understand the current state. TheserviceLauncherClient.tsnow uses a multi-value discriminator withoperation: Schema.Literals([...])which aligns with the prior suggestion, though the error still derives its message from a free-formreasonstring rather than structured attributes — this was flagged before but arguably addressed by the multi-value discriminator approach. Moving on tobootService.ts, I'm examining how thewriteDurablyhelper interacts with the filesystem. Tests shouldn't be re-flagged since they were already caught in the prior run. For non-service modules like servicePreflight.ts and serviceProtocol.ts, named imports are fine. Now I need to check what's actually changed in the current head across key files like serverActivation.ts, serverRuntimeStartup.ts, Sqlite.ts, and server.ts to identify new findings. Sqlite.ts looks like it addressed the prior issue by usingyield* ServiceLauncherClientin Layer.unwrap, but I need to verify whether the named import of ServiceLauncherClient is still problematic. In server.ts, the double-provided launcher layer issue appears resolved — launcherClient is constructed once via ServiceLauncherClient.make() inside Layer.unwrap, which is fine. But I'm noticing ServiceLauncherClient is defined as a Context.Reference with a defaultValue rather than Context.Service with an inline interface, which might be a pattern deviation worth examining. So the current serviceLauncherClient.ts file has the Context.Reference definitions and amakeexport, but nolayerexport — instead, consumers are manually wrapping it withLayer.succeedin server.ts. The convention seems to be that the service should export the layer directly rather than requiring consumers to construct it themselves. TheLayer.succeedapproach in server.ts is actually the right call here—the client needs to be constructed once and shared across multiple sub-layers, so injecting the already-built instance is the idiomatic pattern rather than exporting a separate canonical layer. I should pull the actual files to verify the current state of serverRuntimeStartup.ts, serverActivation.ts, and how Sqlite.ts is being imported rather than relying on the diff alone. I'm noticing something in bootService.ts though—writeDurablyis usingEffect.tryPromisewith the node:fs/promises API directly instead of leveraging the FileSystem service that's already available in the Effect context. That's a hidden imperative dependency that bypasses the service abstraction. There's also an older helperwriteFileStringAtomicallyin atomicWrite.ts that selfUpdate.ts used to call, but bootService reimplements this logic with raw Node APIs instead. That's duplication, and the error handling loses path context when it wraps the error inBootServiceInstallError. Let me check the atomicWrite definition and whether the test coverage changes I noted earlier still hold up. I'm noticing thatbootService.tsintroduces awriteDurablyfunction that circumvents the acquiredFileSystemandPathservices by directly importing from node:fs/promises, using a manual dirname implementation withfilePath.slice(...)that's POSIX-specific and potentially buggy for non-standard separators (though the service is Linux-only anyway), and this approach loses path context in error messages. I need to check whether this violates the convention about not hiding dependencies in module globals or singleton service closures. The real issue is that tests providing a fakeFileSystemwouldn't intercept these direct node:fs calls—only the real NodeServices setup would work. The module already has access toFileSystem.FileSystemandPath.Path, plus there's a repo-levelwriteFileStringAtomicallyhelper available. The justification for using raw node is the fsync durability requirement, sinceFileSystemdoesn't expose synchronous operations, and the codebase even added a@effect-diagnostics nodeBuiltinImport:offannotation. Meanwhile,serviceLauncher.tslegitimately uses node builtins for its standalone bundle, so the pattern isn't entirely forbidden. The error handling also looks questionable—BootServiceInstallError({ cause })passes only the cause with a generic message, which might violate the convention about preserving real causes without inventing redundant singleton fields. The filePath is available at the wrapping site and represents genuine context, butBootServiceInstallError's schema only acceptscause. Since the error class predates this change and is used uniformly throughout with the same pattern, flagging it might be overreach—the convention says to focus on violations introduced or retained in the pull request's scope, and this is a pre-existing pattern. The real question now is what changed between the last reviewed commit and the current one, so I need to compare those two states to identify what's actually new. The prior commit was likely rebased away, so I can't generate a diff. I'm reviewing the full current diff instead and tracking which issues I've already flagged to avoid repeating them—the named import ofmakein bootService.test.ts and coverage removals in both bootService.test.ts and selfUpdate.test.ts are still present but already noted. Looking through the current changes, most of the prior concerns have been addressed: selfUpdate.ts now properly passes the cause tofailWith, pinnedRuntime's catchTags issue is resolved, the namespace import for serviceLauncherClient is fixed, PinnedRuntimePreflightBlockedError has been added, serviceLauncherClient now uses HostProcessEnvironment and ServiceLauncherHostProcess Context.Reference instead of global process/env, ServiceLauncherClientError has proper structured operations, Sqlite.ts correctly uses the ServiceLauncherClient service, and server.ts avoids the double-layer issue with a single instance via Layer.succeed. Now I'm checking for new issues—I notice Sqlite.ts has a named import of ServiceLauncherClient at a service boundary, which looks like it might be another instance of the same pattern I've been flagging. The module could export a canonical layer usingLayer.effectwith a shared instance, which would handle memoization within a single build — but I'm realizing the sharing concern might be overblown since Layer memoization would naturally handle that across multiple consumers. Looking at the other files:serverRuntimeStartup.tsexports a layer fromlayerWithOptions()and imports the ServiceLauncherClient module, which is fine. Themakefunction there evolved from a constant to one accepting options, which aligns with the conventions. InserverActivation.ts, there's aServerActivationContext.Reference holding an Effect that can be void or undefined. Moving through the remaining files,selfUpdate.tsusesEffect.fnwith a generator function and now references ServiceLauncherClient for resolving the self-update capability. ThenservicePreflight.tsis a pure synchronous function using node:sqlite with manual decoding instead of Schema — I'm noticing the repo has disabled thepreferSchemaOverJsondiagnostic in certain places, andserviceProtocol.tsalso does manual decoding rather than relying on Schema, which might be a convention violation depending on how strict the error-handling checks are. Actually, looking closer, the manual decoders aren't errors themselves, andserviceProtocol.tsis intentionally kept dependency-free since it ships in the standalone launcher bundle with only Node built-ins — that's documented in theserviceLauncher.tsheader, so the manual decoding approach is justified there. Incli/servicePreflight.ts, there's a command handler that calls a sync function which can throw, butrunServicePreflightcatches those exceptions, and the effect is properly voided. Now moving intoProviderCommandReact... TheProviderCommandReactor.tschange replacesEffect.failCause(cause)withEffect.interrupt— it's a behavior change but doesn't violate service conventions. Inserver.ts, the HTTP middleware usesEffect.orDieon a typed startup failure at the boundary, which isn't strictly a service-convention violation. ButbootService.tshaswriteDurablybypassing the FileSystem service entirely — that's a real candidate issue under the rule about not hiding dependencies in module globals or closures over singleton services. The pattern is suspicious:bootServiceuses the injectedfsservice everywhere except for this durable write, which calls raw Node APIs withprocess.pid. There's alreadywriteFileStringAtomicallyinatomicWrite.tsusing the FileSystem service properly, andselfUpdate.tsused to call it before this PR removed that usage. NowbootServiceis a third copy of the same logic, creating an imperative bridge inside an Effect service where the dependency is already available — that's exactly what the convention check should catch. The real issue is thatwriteDurablybypasses the FileSystem layer entirely, so tests can't intercept it, and the path manipulation usesfilePath.slice(0, Math.max(filePath.lastIndexOf("/"),1))instead of the Path service that's already there. The fix would be either reusingwriteFileStringAtomicallyor wrapping the fsync durability requirement behind a small injected service so the dependency stays visible to the Effect environment. So the test coupling is real—bootService.test.ts relies on the actual filesystem rather than a fake one, which means it's genuinely dependent on the environment. I'll note this as a suggestion. Looking atpinnedRuntime.ts, I noticeensurePinnedRuntimeInstalledtakesfs,path, andrunneras explicit input parameters instead of pulling them from the environment—the function signature shows these are passed in directly. This matches the exact pattern I'm flagging: production services should acquire Effect dependencies from the environment usingyield* Foo.Foo, not accept implementations as constructor arguments. The tests confirm this by passingfs,path, and asuccessfulRunnermock, and bootService does the same. But I need to check if this was already the case before the changes—the old version of pinnedRuntime.ts also took these parameters in its input. Looking at the diff, the removed code showsrunner.run(...),fs.remove(...), and the type hadreadonly fs: FileSystem.FileSystem; readonly path: Path.Path;. So this pattern existed before and hasn't changed except for the addition ofvalidate. SinceensurePinnedRuntimeInstalledis just a module-level helper function (not a servicemake), not a Context.Service, the convention doesn't really apply here—it's about service construction specifically. And since this was pre-existing and unchanged in signature, prior runs didn't flag it either. I'll move past this one. ForserviceLauncherClient.ts, theServiceLauncherHostProcessContext.Reference withdefaultValuereading globalprocessfollows the standard host-reference pattern, same as what's in shared. That's fine. Now looking atServiceLauncherClientError: it hasoperationwith multiple literal values,reason: Schema.String, and an optionalcause. The message is derived from the reason string—reason IS the message. There's a discriminator onoperationtoo. The prior run actually suggested this exact design with a multi-value discriminator, so the author followed that guidance. But the conventions also say the message should be derived from the operation itself plus structured fields, not stored as free-form text. Looking at the call sites: "unmanaged" appears twice with the same string, decode-context has a fixed string, version-mismatch has a templated string with childVersion/currentVersion. Meanwhile "rejected" carries genuinely dynamic data from the launcher's IPC response. This is a partially-addressed prior finding—flagging the same error class again risks being repetitive, but the core issue remains: either separate tagged errors per failure mode, or keep the one error with a multi-value discriminator but derive the message from operation plus structured fields rather than storing arbitrary reason text. The author chose option 2 but kept reason as the message payload, which is acceptable enough that I won't re-flag it. Now I need to check selfUpdate.ts to see how it's structured and whether it uses a layer pattern with failWith for error construction, then look at server.ts, Sqlite.ts, and serviceLauncherClient.ts to confirm the patterns. Looking at the error handling in selfUpdate.ts — the blocked preflight case usesfailWithto pass the reason from a structured error, which is now properly tagged, whileServerSelfUpdateErrorhandles the contract error with a free-form reason field. For Sqlite.ts, I'm using a named import forServiceLauncherClientto match the existing pattern withServerConfig. The convention flags aliases that erase module namespaces, but a plain named import of the tag itself is only a partial violation. Since the file already follows this style locally and this is just one new line, I don't think it's worth flagging — the check defaults to failure and I should focus on real issues rather than inventing violations. Let me examine serviceLauncherClient.ts and serverRuntimeStartup.ts more closely for actual problems. I'm noticing that themakefunction uses a localfailhelper that just wraps error construction with optional cause branching, which violates the guideline against adding helpers that only construct errors. The same issue appears to be present infailWith. Looking atselfUpdate.ts, it seemsfailWithis pre-existing and was restored to accept a cause parameter. Thefailhelper inserviceLauncherClientis new and used about eight times—it's a thin factory that branches on whether cause is present, which technically might be unnecessary ifSchema.optionalallows undefined values directly, but the branching could be justified by TypeScript's exactOptionalPropertyTypes. It's borderline whether this warrants flagging since the same pattern already exists elsewhere. On thelayerexport:serviceLauncherClientdeliberately omits it, requiring consumers to construct and share a single layer instance manually. The server does this by creating the launcher client once and providing that same layer to multiple dependents. It's a convention gap—normally you'd export the canonical layer—but the design is intentional and acceptable for ensuring a shared singleton. As for usingContext.Referenceinstead ofContext.Service: that's the repo's pattern for optional services with default no-op implementations. Several modules likeServerActivationandHostProcessfollow this same approach, so it aligns with established conventions even though the standard order would useContext.Servicewith an inline interface. Now I'm checkingserverRuntimeStartup.tsto see if it properly acquires the launcher from the environment and handles the activation options correctly. I'm tracing through howServiceLauncherClient.make()gets invoked within theLayer.unwrapeffect in makeServerLayer — its dependencies resolve automatically since they're references with defaults, and the error type propagates correctly. But I need to think through the scope implications of calling this inside the unwrap effect... Themakefunction returns a plain object without any special scoping requirements, so that's straightforward. The listeners get registered lazily during eachexchangecall, not upfront, so there's no scope issue there either. Now I'm checking whetherServerSelfUpdate.layerproperly receivesServiceLauncherClient— it's satisfied through thelauncherLayerthat gets provided tomakeRoutesLayerin server.ts, andProcessRunner.layeris also provided inside selfUpdate's own layer, so that chains correctly. But I'm concerned about a silent-default risk: ifServerSelfUpdate.layerrequiresServiceLauncherClientas a Reference with a default, other compositions like MCP or tests could accidentally satisfy it with the default instead of an explicit layer. And looking atSqlite.ts, its layerConfig also requiresServiceLauncherClientas a Reference with default, which means if the launcher layer isn't explicitly provided to the persistence layer, it silently falls back to trial=false — I need to verify whether server.ts actually provides it there. The Reference-with-default pattern hides the requirement from types, which arguably violates the convention that production service dependencies must be exposed in their types — but References are an accepted idiom across the codebase, and prior review already covered this area, so I'll skip flagging it. Now I'm scanning the diff for clear convention violations, starting with the CLI imports. There's also thewriteDurablyfunction inbootService.tsthat could be a candidate. Let me verify how the CLI imports are structured across the codebase. This aligns with the repo's patterns, so it's not a real issue. Now I'm shifting focus to thewriteDurablyfinding to see if there's anything more substantial there, and I want to check the rest of bootService.ts—specifically the install, status, and uninstall methods—to understand the conventions being used. I'm noticing a convention issue in how the bootService.ts install function handles errors. It's collapsing a structuredPinnedRuntimeInstallErrorthat contains step and exit code information into a generic install error with a generic message about setting up the background service. The previous approach was more granular—it mapped install-step failures toBootServiceCommandErrorwhile preserving those details, and handled non-install failures separately. The problem is thatBootServiceErroralready includesBootServiceCommandErrorwithstepandexitCodefields, which is exactly what the pinned runtime error carries. By wrapping everything in a generic error, the caller-visible message loses specificity like "failed while installing the pinned t3 runtime (exit code N)"—and since the CLI surfaces these messages directly to users, this is a concrete regression in error clarity at a user-facing boundary. losing the step and exit code context that was previously mapped to BootServiceCommandError. The diff removed that error branching logic, so install-step failures now collapse into a generic BootServiceInstallError without the structured context. Finding #2 is thatwriteDurablybypasses the injected FileSystem and Path services, duplicating atomic write logic and losing file path context in errors — if durability is needed, the effect's fs.open with sync support would be the proper approach within the service boundary. Now I'm checking whether effect's FileSystem File API has sync support and looking at how the repo currently uses it. I'm noticing there's asystemdSelfUpdate.tsfile at the current head that doesn't appear in the diff, which suggests it might be a pre-existing module rather than something added by this PR. Let me trace through whether this file existed at the merge base to understand what's actually being changed with the selfUpdate code. There's a discrepancy between what git_grep found at that revision versus what the tree view is showing. Git_grep should default to the cloned commit (the PR head for reviews), but maybe it's actually searching a different revision, or the tree view is resolving to the default branch head instead. I should try running git_grep with an explicit revision to clarify which commit is actually being searched. ThesystemdSelfUpdate.tsfile exists unchanged at the merge base, so it's not relevant to this diff. Now I'm identifying the key findings to document, starting with howbootService.tsat line 286 is collapsing the structuredPinnedRuntimeInstallErrorinto a genericBootServiceInstallError, which strips away the step information, exit codes, and user-facing messages that were previously preserved. Looking at the error handling convention here:PinnedRuntimeInstallErrorisn't part of theBootServiceErrorunion, so it needs to be wrapped—but the wrapping should maintain structural context by mapping it toBootServiceCommandErrorwith the step details intact. The previous code did exactly that, so this is a violation of the pattern "Map failures where that context is known instead of wrapping an entire multi-step pipeline in one generic error." For the second finding aroundwriteDurably, I'm checking whether using imperative Node fs directly inside the service—bypassing the injectedFileSystem/Pathabstraction—actually violates the dependency convention about not hiding dependencies in module globals or closures. The service declares aFileSystemrequirement but the actual writes go through the global node API, making that dependency invisible. There's also a correctness issue with the manual path slicing instead of using the injectedpath.dirname, though the service is Linux-gated so it might not manifest. I'll frame this as a suggestion to use the injected abstractions and the existingwriteFileStringAtomicallyhelper instead of the global APIs, keeping it concise. ThePinnedRuntimePreflightBlockedErrorstructure withversionandreasonfields works fine—the reason is dynamically sourced from the preflight child process, so it can't be derived statically, which is acceptable. This approach was already flagged in prior review. Now I'm weighing the main finding: the bootService install error mapping has regressed from structured to generic messaging. The old code preserved specific context like exit codes and step information in user-facing messages, but the refactor wraps everything under a generic "Could not set up the T3 Code background service" message. TheBootServiceInstallErrordoes maintain the cause chain viaSchema.Defect(), but we've lost the granular error details that were previously logged. The fix would be mappingPinnedRuntimeInstallErrortoBootServiceCommandErrorwhile preserving the step, exit code, and stderr information. Writing the error mapping... WithexactOptionalPropertyTypesenabled, passing the optionalexitCodefield should work fine since the schema allows undefined values. For the inline comment, I need to target line 286 in the file—that's where the original error mapping line appears in the diff hunk, and it's definitely part of the changed section. Now looking at thewriteDurablyfunction added at lines 145-171. The issue is it's using the globalnode:fs/promisesand manually computing the directory path instead of leveraging theFileSystemandPathservices that themakefunction already provides. This means the service loses its filesystem dependency from the Effect environment, making it impossible for tests to substitute a mock FileSystem layer, and there's also duplication of the atomic write logic. The suggestion would be to perform the write through the Effect FileSystem service instead, which should have asyncmethod available for ensuring durability. I'm deciding whether to fail the check based on the findings—there's a moderate error-context regression and a dependency-acquisition deviation that qualify as convention violations. I'll post the review with inline comments at the specific line numbers where these issues occur.
1. modify_pr — Two findings in apps/server/src/cloud/bootService.ts. Previously reported issues that were addressed (namespace import for serviceLauncherClient, Effect.catchTags in pinnedRuntime.ts, structured ServiceLauncherClientError/PinnedRuntimePreflightBlockedError, launcher client acquired from the environment and composed once) are not repeated.
Agent Credits: 257 credits