Skip to content

port desktop app to Effect#2546

Merged
juliusmarminge merged 44 commits intomainfrom
t3code/a1238be3
May 8, 2026
Merged

port desktop app to Effect#2546
juliusmarminge merged 44 commits intomainfrom
t3code/a1238be3

Conversation

@juliusmarminge
Copy link
Copy Markdown
Member

@juliusmarminge juliusmarminge commented May 6, 2026

Summary

  • Reworked desktop backend readiness probing around Effect-based services and effects, with temporary promise shims preserved for existing call sites.
  • Added Effect/Vitest coverage for readiness polling, request timeouts, abort handling, and backend startup race behavior.
  • Updated the desktop main process to use typed URL handling for backend endpoints and the new readiness error type.

Testing

  • bun fmt
  • bun lint
  • bun typecheck
  • bun run test for apps/desktop/src/backendReadiness.test.ts and apps/desktop/src/backendStartupReadiness.test.ts

Note

High Risk
Large refactor of the Electron desktop main process, backend spawning/readiness, and lifecycle handling; mistakes could prevent the app from starting or shut down cleanly. Also changes runtime configuration/paths and protocol handling, which can impact packaging and local file access.

Overview
Ports the desktop Electron main process to an Effect-based architecture, introducing layered services for app identity/environment/config, lifecycle/shutdown coordination, observability/tracing, and Electron wrappers (app/dialog/menu/protocol).

Reworks desktop backend startup into Effect-managed spawning with fd-based bootstrap payloads, HTTP readiness polling, structured child-process output logging, and automatic restart scheduling/cancellation logic.

Cleans up legacy promise/imperative utilities and tests (e.g., backendPort, readiness helpers, settings/persistence/dialog helpers) in favor of new Effect/Vitest test coverage, and updates build hygiene (.gitignore consolidation) plus bumps electron to 41.5.0.

Reviewed by Cursor Bugbot for commit 35835a7. Bugbot is set up for automated code reviews on this repo. Configure here.

Note

Port desktop app to Effect by replacing imperative Electron code with layered Effect-TS services

  • Rewrites main.ts from imperative Electron startup code to a declarative Effect-TS layer composition, using NodeRuntime.runMain to execute a DesktopApp.program with all services provided via layers.
  • Introduces a large set of new Effect-based Electron service wrappers: ElectronApp, ElectronDialog, ElectronMenu, ElectronProtocol, ElectronSafeStorage, ElectronShell, ElectronTheme, ElectronUpdater, and ElectronWindow, each with typed errors and scoped resource management.
  • Adds desktop domain services (DesktopEnvironment, DesktopAppSettings, DesktopClientSettings, DesktopSavedEnvironments, DesktopBackendManager, DesktopBackendConfiguration, DesktopServerExposure, DesktopSshEnvironment, DesktopUpdates, DesktopWindow, etc.) as injectable Effect layers.
  • Moves shared observability utilities (trace sink, NDJSON tracer, compactTraceAttributes) from the server app to @t3tools/shared/observability, and moves DesktopBackendBootstrap schema to @t3tools/contracts.
  • Adds a comprehensive set of typed IPC handlers via DesktopIpc and installDesktopIpcHandlers, covering settings, SSH, server exposure, updates, and window operations; the preload bridge now sends structured object payloads instead of positional arguments.
  • Converts all Effect imports across packages/contracts, packages/ssh, packages/shared, packages/tailscale, and related scripts to namespaced form, enforced by @effect/language-service plugin added to multiple tsconfig.json files.
  • Risk: main.ts is a wholesale rewrite; any existing behavior not covered by the new layers (imperative window creation, old IPC handler signatures, title bar overlay config) is removed or changed.

Macroscope summarized 35835a7.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 6, 2026

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 43ea1a9d-4a21-4540-b157-0dd7ba97ae0b

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch t3code/a1238be3

Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions github-actions Bot added size:L 100-499 changed lines (additions + deletions). vouch:trusted PR author is trusted by repo permissions or the VOUCHED list. labels May 6, 2026
Comment thread apps/desktop/src/backendReadiness.ts Outdated
Copy link
Copy Markdown
Contributor

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Autofix Details

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Timeout silently succeeds instead of failing with error
    • Added Option.match after Effect.timeoutOption to convert Option.None (timeout) into a BackendTimeoutError failure instead of silently succeeding, matching the established pattern used in all other timeoutOption call sites in the codebase.

Create PR

Or push these changes by commenting:

@cursor push 27f2b65c9b
Preview (27f2b65c9b)
diff --git a/apps/desktop/src/backendReadiness.ts b/apps/desktop/src/backendReadiness.ts
--- a/apps/desktop/src/backendReadiness.ts
+++ b/apps/desktop/src/backendReadiness.ts
@@ -3,6 +3,7 @@
 import * as Data from "effect/Data";
 import * as Duration from "effect/Duration";
 import * as Effect from "effect/Effect";
+import * as Option from "effect/Option";
 import * as Predicate from "effect/Predicate";
 import * as Schedule from "effect/Schedule";
 import { HttpClient } from "effect/unstable/http";
@@ -60,9 +61,13 @@
   yield* client.get(requestUrl).pipe(
     Effect.asVoid,
     Effect.timeoutOption(timeout),
+    Effect.flatMap(
+      Option.match({
+        onNone: () => Effect.fail(new BackendTimeoutError({ url: baseUrl })),
+        onSome: () => Effect.void,
+      }),
+    ),
     Effect.catchTags({
-      TimeoutError: () => Effect.fail(new BackendTimeoutError({ url: baseUrl })),
-      // Maybe map this to different error kind?
       HttpClientError: () => Effect.fail(new BackendTimeoutError({ url: baseUrl })),
     }),
   );

You can send follow-ups to the cloud agent here.

Comment thread apps/desktop/src/backendReadiness.ts Outdated
@macroscopeapp
Copy link
Copy Markdown
Contributor

macroscopeapp Bot commented May 6, 2026

Approvability

Verdict: Needs human review

Diff is too large for automated approval analysis. A human reviewer should evaluate this PR.

You can customize Macroscope's approvability policy. Learn more.

@juliusmarminge
Copy link
Copy Markdown
Member Author

@cursor push 27f2b65

macroscopeapp[bot]
macroscopeapp Bot previously approved these changes May 6, 2026
@macroscopeapp macroscopeapp Bot dismissed their stale review May 6, 2026 07:20

Dismissing prior approval to re-evaluate d8f0d72

macroscopeapp[bot]
macroscopeapp Bot previously approved these changes May 6, 2026
@macroscopeapp macroscopeapp Bot dismissed their stale review May 6, 2026 07:59

Dismissing prior approval to re-evaluate b5c8ea4

@github-actions github-actions Bot added size:XL 500-999 changed lines (additions + deletions). and removed size:L 100-499 changed lines (additions + deletions). labels May 6, 2026
Comment thread apps/desktop/src/main.ts Outdated
Copy link
Copy Markdown
Contributor

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 2 potential issues.

Autofix Details

Bugbot Autofix prepared fixes for both issues found in the latest run.

  • ✅ Fixed: Port fields lose range validation in bootstrap schema
    • Replaced Schema.Number with a shared PortSchema (Schema.Int with isBetween 1-65535) for both port and tailscaleServePort fields in DesktopBackendBootstrap, restoring the validation that was present in the old BootstrapEnvelopeSchema.
  • ✅ Fixed: Readiness timeout fires but no window ever created
    • Added fallback window creation in the onReadinessFailure handler so users get a window (showing reconnection state) instead of being stuck with no UI when the readiness probe times out.

Create PR

Or push these changes by commenting:

@cursor push 1291f41e07
Preview (1291f41e07)
diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts
--- a/apps/desktop/src/main.ts
+++ b/apps/desktop/src/main.ts
@@ -1496,6 +1496,12 @@
             `bootstrap backend readiness warning message=${formatErrorMessage(error)}`,
           );
           console.warn("[desktop] backend readiness check failed during bootstrap", error);
+
+          if (isDevelopment) return;
+          const existingWindow = mainWindow ?? BrowserWindow.getAllWindows()[0] ?? null;
+          if (existingWindow !== null) return;
+          mainWindow = createWindow();
+          writeDesktopLogHeader("bootstrap main window created (readiness timeout fallback)");
         }),
       onOutput: (streamName, chunk) =>
         Effect.sync(() => {

diff --git a/apps/server/src/cli.ts b/apps/server/src/cli.ts
--- a/apps/server/src/cli.ts
+++ b/apps/server/src/cli.ts
@@ -5,6 +5,7 @@
   CommandId,
   DesktopBackendBootstrap,
   OrchestrationReadModel,
+  PortSchema,
   ProjectId,
   type ClientOrchestrationCommand,
 } from "@t3tools/contracts";
@@ -67,8 +68,6 @@
 import { WorkspacePaths } from "./workspace/Services/WorkspacePaths.ts";
 import { WorkspacePathsLive } from "./workspace/Layers/WorkspacePaths.ts";
 
-const PortSchema = Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 65535 }));
-
 const modeFlag = Flag.choice("mode", RuntimeMode.literals).pipe(
   Flag.withDescription("Runtime mode. `desktop` keeps loopback defaults unless overridden."),
   Flag.optional,

diff --git a/packages/contracts/src/baseSchemas.ts b/packages/contracts/src/baseSchemas.ts
--- a/packages/contracts/src/baseSchemas.ts
+++ b/packages/contracts/src/baseSchemas.ts
@@ -5,6 +5,7 @@
 
 export const NonNegativeInt = Schema.Int.check(Schema.isGreaterThanOrEqualTo(0));
 export const PositiveInt = Schema.Int.check(Schema.isGreaterThanOrEqualTo(1));
+export const PortSchema = Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 65535 }));
 
 export const IsoDateTime = Schema.String;
 export type IsoDateTime = typeof IsoDateTime.Type;

diff --git a/packages/contracts/src/desktopBootstrap.ts b/packages/contracts/src/desktopBootstrap.ts
--- a/packages/contracts/src/desktopBootstrap.ts
+++ b/packages/contracts/src/desktopBootstrap.ts
@@ -1,14 +1,16 @@
 import { Schema } from "effect";
 
+import { PortSchema } from "./baseSchemas.ts";
+
 export const DesktopBackendBootstrap = Schema.Struct({
   mode: Schema.Literal("desktop"),
   noBrowser: Schema.Boolean,
-  port: Schema.Number,
+  port: PortSchema,
   t3Home: Schema.String,
   host: Schema.String,
   desktopBootstrapToken: Schema.String,
   tailscaleServeEnabled: Schema.Boolean,
-  tailscaleServePort: Schema.Number,
+  tailscaleServePort: PortSchema,
   otlpTracesUrl: Schema.optional(Schema.String),
   otlpMetricsUrl: Schema.optional(Schema.String),
 });

You can send follow-ups to the cloud agent here.

Comment thread packages/contracts/src/desktopBootstrap.ts Outdated
Comment thread apps/desktop/src/backendProcess.ts Outdated
@github-actions github-actions Bot added size:XXL 1,000+ changed lines (additions + deletions). and removed size:XL 500-999 changed lines (additions + deletions). labels May 6, 2026
Comment thread apps/desktop/src/main.ts Outdated
Comment thread apps/desktop/src/backend/DesktopBackendManager.ts
Comment thread apps/desktop/src/main.ts
Comment thread apps/desktop/src/main.ts Outdated
Comment thread apps/desktop/src/backendPort.ts Outdated
Comment thread apps/desktop/src/backend/DesktopBackendManager.ts
@cursor
Copy link
Copy Markdown
Contributor

cursor Bot commented May 6, 2026

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Start leaks restart fiber without interrupting it
    • Added a call to the existing cancelRestart helper before the state update in start(), which properly interrupts the restart fiber before clearing its reference, preventing the orphaned fiber leak.

Create PR

Or push these changes by commenting:

@cursor push fbac652b11
Preview (fbac652b11)
diff --git a/apps/desktop/src/desktopBackendManager.ts b/apps/desktop/src/desktopBackendManager.ts
--- a/apps/desktop/src/desktopBackendManager.ts
+++ b/apps/desktop/src/desktopBackendManager.ts
@@ -232,11 +232,11 @@
           .exists(config.entryPath)
           .pipe(Effect.orElseSucceed(() => false));
 
+        yield* cancelRestart;
         yield* Ref.update(state, (latest) => ({
           ...latest,
           desiredRunning: true,
           ready: false,
-          restartFiber: Option.none(),
         }));
 
         if (!entryExists) {

You can send follow-ups to the cloud agent here.

Copy link
Copy Markdown
Contributor

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

There are 4 total unresolved issues (including 3 from previous reviews).

Autofix Details

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Restart backoff resets on spawn, not readiness
    • Moved the restartAttempt: 0 reset from the onStarted callback to the onReady callback so exponential backoff is maintained for backends that crash during initialization.

Create PR

Or push these changes by commenting:

@cursor push 269ea9ef0e
Preview (269ea9ef0e)
diff --git a/apps/desktop/src/desktopBackendManager.ts b/apps/desktop/src/desktopBackendManager.ts
--- a/apps/desktop/src/desktopBackendManager.ts
+++ b/apps/desktop/src/desktopBackendManager.ts
@@ -324,16 +324,13 @@
                   ...run,
                   pid: Option.some(pid),
                 }));
-                yield* Ref.update(state, (latest) => ({
-                  ...latest,
-                  restartAttempt: 0,
-                }));
                 yield* events.onStarted({ pid, config });
               }),
             onReady: () =>
               Effect.gen(function* () {
                 yield* Ref.update(state, (latest) => ({
                   ...latest,
+                  restartAttempt: 0,
                   ready: Option.match(latest.active, {
                     onNone: () => latest.ready,
                     onSome: (run) => (run.id === runId ? true : latest.ready),

You can send follow-ups to the cloud agent here.

Comment thread apps/desktop/src/desktopBackendManager.ts Outdated
Co-authored-by: codex <codex@users.noreply.github.com>
- Remove explicit shutdown paths
- Rely on scoped finalizers for backend, updates, and SSH prompts
- Co-authored-by: codex <codex@users.noreply.github.com>
);
});

const startUpdatePollers: Effect.Effect<void, never, Scope.Scope> = Effect.gen(function* () {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Low updates/DesktopUpdates.ts:408

The polling loop at startUpdatePollers breaks permanently when a defect (runtime exception) occurs during checkForUpdates. The Effect.catchCause is placed outside Effect.forever, so when an uncaught defect propagates from the inner effect, catchCause logs it and returns, terminating the fiber. Since checkForUpdates only uses Effect.catch for typed errors, any unexpected throw from electronUpdater.checkForUpdates or other operations stops update polling until app restart. Consider moving catchCause inside forever so each iteration is isolated, or wrapping checkForUpdates with Effect.catchAllDefect to ensure defects are caught and logged without breaking the loop.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file apps/desktop/src/updates/DesktopUpdates.ts around line 408:

The polling loop at `startUpdatePollers` breaks permanently when a defect (runtime exception) occurs during `checkForUpdates`. The `Effect.catchCause` is placed **outside** `Effect.forever`, so when an uncaught defect propagates from the inner effect, `catchCause` logs it and returns, terminating the fiber. Since `checkForUpdates` only uses `Effect.catch` for typed errors, any unexpected throw from `electronUpdater.checkForUpdates` or other operations stops update polling until app restart. Consider moving `catchCause` inside `forever` so each iteration is isolated, or wrapping `checkForUpdates` with `Effect.catchAllDefect` to ensure defects are caught and logged without breaking the loop.

Evidence trail:
apps/desktop/src/updates/DesktopUpdates.ts lines 408-426 (startUpdatePollers with Effect.forever before Effect.catchCause in pipe chain); lines 303-336 (checkForUpdates using only Effect.catch for typed errors, not catching defects); lines 324-326 (electronUpdater.checkForUpdates piped through Effect.catch only)

- Remove the DesktopRun service and derive run IDs from scoped log annotations
- Update backend lifecycle and menu logging to use Effect logging helpers
- Adjust tests for backend restarts and menu behavior
Copy link
Copy Markdown
Contributor

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

There are 2 total unresolved issues (including 1 from previous review).

Autofix Details

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Misleading log emitted when backend start is skipped
    • Moved the log statement inside the conditional block and added a distinct "skipped" log for the quitting case, so logs now accurately reflect whether the backend was started or skipped.

Create PR

Or push these changes by commenting:

@cursor push 5f643f6923
Preview (5f643f6923)
diff --git a/apps/desktop/src/app/DesktopApp.ts b/apps/desktop/src/app/DesktopApp.ts
--- a/apps/desktop/src/app/DesktopApp.ts
+++ b/apps/desktop/src/app/DesktopApp.ts
@@ -178,8 +178,10 @@
 
   if (!(yield* Ref.get(state.quitting))) {
     yield* backendManager.start;
+    yield* Effect.logInfo("bootstrap backend start requested");
+  } else {
+    yield* Effect.logInfo("bootstrap backend start skipped (quitting)");
   }
-  yield* Effect.logInfo("bootstrap backend start requested");
 
   if (environment.isDevelopment) {
     yield* desktopWindow.ensureMain;

You can send follow-ups to the cloud agent here.

Comment thread apps/desktop/src/app/DesktopApp.ts Outdated
- Switch desktop backend readiness checks to `/.well-known/t3/environment`
- Update tests to expect the new probe URL
- Persist backend output and desktop logs in development
- Delay dev window creation until backend is ready
- Move Electron scheme privilege registration into startup layer
Copy link
Copy Markdown
Contributor

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

There are 2 total unresolved issues (including 1 from previous review).

Fix All in Cursor

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: desktopState.backendReady is never set to true
    • Added Ref.set(desktopState.backendReady, true) in the onReady callback and removed the manual workaround in the test that externally set it to true.

Create PR

Or push these changes by commenting:

@cursor push e5394e09b4
Preview (e5394e09b4)
diff --git a/apps/desktop/src/backend/DesktopBackendManager.test.ts b/apps/desktop/src/backend/DesktopBackendManager.test.ts
--- a/apps/desktop/src/backend/DesktopBackendManager.test.ts
+++ b/apps/desktop/src/backend/DesktopBackendManager.test.ts
@@ -328,7 +328,6 @@
         yield* manager.start;
         assert.equal(yield* Queue.take(startedPids), 123);
         yield* Deferred.await(ready);
-        yield* Ref.set(backendReady, true);
         assert.isTrue(yield* Ref.get(backendReady));
         assert.deepEqual(yield* manager.currentConfig, Option.some(baseConfig));
 

diff --git a/apps/desktop/src/backend/DesktopBackendManager.ts b/apps/desktop/src/backend/DesktopBackendManager.ts
--- a/apps/desktop/src/backend/DesktopBackendManager.ts
+++ b/apps/desktop/src/backend/DesktopBackendManager.ts
@@ -443,6 +443,7 @@
                   onSome: (run) => (run.id === runId ? true : latest.ready),
                 }),
               }));
+              yield* Ref.set(desktopState.backendReady, true);
               yield* desktopWindow.handleBackendReady.pipe(
                 Effect.catch((error) =>
                   Effect.logError("failed to open main window after backend readiness").pipe(

You can send follow-ups to the cloud agent here.

Reviewed by Cursor Bugbot for commit 413d2f9. Configure here.

Comment thread apps/desktop/src/backend/DesktopBackendManager.ts Outdated
- Move trace sink and OTLP helpers into shared observability code
- Add desktop trace export and structured backend child logging
- Update server observability wiring and tests for new trace records
Comment thread apps/desktop/src/backend/DesktopBackendManager.ts Outdated
- Ignore stale ready signals from previous runs
- Skip scheduled restarts once desiredRunning is false
- Add regression coverage for stop cancelling a pending restart
- wrap startup, lifecycle, backend, IPC, and settings flows with spans
- remove the separate desktop-main.log file logger in favor of trace events
- keep observability tests aligned with span-based logging
Comment thread apps/desktop/src/electron/ElectronWindow.ts
Co-authored-by: codex <codex@users.noreply.github.com>
- Ignore windows that are destroyed before sync runs
- Add coverage for appearance sync filtering
- Introduce a shared component logger helper for desktop services
- Replace ad hoc log annotations across startup, backend, updates, menu, and window code
@juliusmarminge juliusmarminge merged commit aa219be into main May 8, 2026
12 checks passed
@juliusmarminge juliusmarminge deleted the t3code/a1238be3 branch May 8, 2026 04:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:XXL 1,000+ changed lines (additions + deletions). vouch:trusted PR author is trusted by repo permissions or the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants