Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
50 commits
Select commit Hold shift + click to select a range
f83d73b
fix(server): fall back to direct fd read on EACCES bootstrap
Jgratton24 May 11, 2026
0ae9a14
feat(desktop): add WSL backend mode
Jgratton24 May 11, 2026
3b9f86c
fix(desktop): address bot review findings on WSL backend swap
Jgratton24 May 11, 2026
80eddbb
fix(desktop): clear swap flow timer in finally + drop duplicate env-s…
Jgratton24 May 11, 2026
3b18e6d
fix(desktop): resolve WSL user home for tilde paths, fix select value…
Jgratton24 May 11, 2026
b6c25ca
fix(desktop): namespace backend-runtime select sentinels and drop red…
Jgratton24 May 11, 2026
4506e73
fix(desktop): treat re-pick of resolved-default distro as a no-op
Jgratton24 May 11, 2026
6d32481
fix(desktop): widen swap flow ceiling to cover stacked rollback waits
Jgratton24 May 11, 2026
e15edd9
fix(desktop): drop dead WslConfig.enabled and extend toast suppressio…
Jgratton24 May 11, 2026
cb8fbe5
fix(desktop): baseline welcome env-id from the atom to avoid immediat…
Jgratton24 May 11, 2026
4f3a90c
fix(desktop): preserve existing WSLENV entries
Jgratton24 May 16, 2026
b20a966
feat(desktop): scaffold DesktopBackendPool for concurrent backends
Jgratton24 May 16, 2026
a8fc784
feat(desktop): reshape DesktopBackendManager into per-instance factory
Jgratton24 May 16, 2026
425c7d0
feat(desktop): move backend-ready latch from DesktopState onto the wi…
Jgratton24 May 16, 2026
563820e
feat(desktop): route backend child output through a per-instance log …
Jgratton24 May 16, 2026
a0eaf56
feat(desktop): add register/unregister to DesktopBackendPool
Jgratton24 May 16, 2026
b162219
feat(desktop): replace wslMode swap toggle with wslBackendEnabled
Jgratton24 May 17, 2026
31ce3ad
feat(desktop): split DesktopBackendConfiguration into primary + wsl r…
Jgratton24 May 17, 2026
627c80c
feat(desktop): orchestrate the WSL backend as a second pool instance
Jgratton24 May 17, 2026
3fe02a4
docs(desktop): update DesktopBackendPool header for step-5 state
Jgratton24 May 17, 2026
bad6604
feat(desktop): widen local-environment bootstrap IPC to return all in…
Jgratton24 May 17, 2026
5d80468
feat(desktop): route pickFolder by target environment id
Jgratton24 May 17, 2026
eb5a03e
feat(web): replace WSL swap dialog with a toggle + distro picker
Jgratton24 May 17, 2026
60de6af
docs(desktop): refresh DesktopBackendPool header for steps 6-8 state
Jgratton24 May 17, 2026
1c7e787
feat(web): register WSL backend as a desktop-local environment
Jgratton24 May 17, 2026
38e8477
feat(web): allow "Open from file manager" against desktop-local envs
Jgratton24 May 17, 2026
c17897b
fix(web): write WSL bearer before upserting record to avoid auth race
Jgratton24 May 17, 2026
0e1d78e
docs(desktop): record final state + browser validation in pool docblock
Jgratton24 May 17, 2026
df23691
fix(desktop): route WSL renderer fetches through the distro's eth0 IP
Jgratton24 May 18, 2026
2c1620d
fix(server): make the desktop-bootstrap grant reusable for 24h
Jgratton24 May 18, 2026
24b012d
fix(web): retry local-secondary reconcile during WSL cold boot
Jgratton24 May 18, 2026
ad7e3d7
fix(web): use a container icon for desktopLocal threads in the sidebar
Jgratton24 May 18, 2026
0dbdd9f
fix(web): keep auto-retrying when the WSL bootstrap hasn't appeared yet
Jgratton24 May 18, 2026
943e1ed
fix(web): use container icon on desktopLocal-only project headers
Jgratton24 May 18, 2026
6bf864d
fix(web): timeout each WSL register attempt + expose reconciler trace
Jgratton24 May 18, 2026
c47ec5d
feat(web): show a sidebar status alert while WSL backend is connecting
Jgratton24 May 18, 2026
3558e21
fix(web): keep WSL connecting indicator steady between retry attempts
Jgratton24 May 18, 2026
a7a2e1b
fix(web): drop per-thread container icon when the whole project is WSL
Jgratton24 May 18, 2026
449e18f
feat(web): confirm before disable / distro-switch when WSL has state
Jgratton24 May 18, 2026
e00ad61
feat: add wsl-only mode + project icon dedup
Jgratton24 May 18, 2026
3c82c95
feat: auto-restart on wsl-only toggle
Jgratton24 May 18, 2026
d71f3b1
feat(web): consolidate WSL backend toggle and distro picker into one …
Jgratton24 May 18, 2026
1ebb0e5
fix(desktop): load renderer from primary's real URL, not local exposure
Jgratton24 May 18, 2026
05c5295
feat(web): pick WSL mode at enable time, clean up Off-from-wsl-only path
Jgratton24 May 18, 2026
aebb4fa
perf(web): park the local-secondary auto-retry on hosts with no WSL
Jgratton24 May 18, 2026
c3374a8
fix: review feedback from PR #2751
Jgratton24 May 18, 2026
ba31706
fix(desktop): stop every pool instance before update install, not jus…
Jgratton24 May 18, 2026
8a55a55
fix(desktop): stop every pool instance on normal shutdown, not just p…
Jgratton24 May 18, 2026
ce93b7a
fix(desktop): use loopback for WSL backend URL when in mirrored mode
Jgratton24 May 18, 2026
b62348b
fix(desktop): hide preflight-failed bootstraps + serialize WSL reconcile
Jgratton24 May 18, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 23 additions & 5 deletions apps/desktop/src/app/DesktopApp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import * as ElectronProtocol from "../electron/ElectronProtocol.ts";
import { installDesktopIpcHandlers } from "../ipc/DesktopIpcHandlers.ts";
import * as DesktopAppIdentity from "./DesktopAppIdentity.ts";
import * as DesktopApplicationMenu from "../window/DesktopApplicationMenu.ts";
import * as DesktopBackendManager from "../backend/DesktopBackendManager.ts";
import * as DesktopBackendPool from "../backend/DesktopBackendPool.ts";
import * as DesktopEnvironment from "./DesktopEnvironment.ts";
import * as DesktopLifecycle from "./DesktopLifecycle.ts";
import * as DesktopObservability from "./DesktopObservability.ts";
Expand All @@ -21,6 +21,7 @@ import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts";
import * as DesktopShellEnvironment from "../shell/DesktopShellEnvironment.ts";
import * as DesktopState from "./DesktopState.ts";
import * as DesktopUpdates from "../updates/DesktopUpdates.ts";
import * as DesktopWslBackend from "../wsl/DesktopWslBackend.ts";

const DEFAULT_DESKTOP_BACKEND_PORT = 3773;
const MAX_TCP_PORT = 65_535;
Expand Down Expand Up @@ -130,11 +131,13 @@ const fatalStartupCause = <E>(stage: string, cause: Cause.Cause<E>) =>
handleFatalStartupError(stage, Cause.pretty(cause)).pipe(Effect.andThen(Effect.failCause(cause)));

const bootstrap = Effect.gen(function* () {
const backendManager = yield* DesktopBackendManager.DesktopBackendManager;
const pool = yield* DesktopBackendPool.DesktopBackendPool;
const primaryBackend = yield* pool.primary;
const state = yield* DesktopState.DesktopState;
const environment = yield* DesktopEnvironment.DesktopEnvironment;
const desktopSettings = yield* DesktopAppSettings.DesktopAppSettings;
const serverExposure = yield* DesktopServerExposure.DesktopServerExposure;
const wslBackend = yield* DesktopWslBackend.DesktopWslBackend;
yield* logBootstrapInfo("bootstrap start");

if (environment.isDevelopment && Option.isNone(environment.configuredBackendPort)) {
Expand Down Expand Up @@ -178,8 +181,13 @@ const bootstrap = Effect.gen(function* () {
yield* logBootstrapInfo("bootstrap ipc handlers registered");

if (!(yield* Ref.get(state.quitting))) {
yield* backendManager.start;
yield* primaryBackend.start;
yield* logBootstrapInfo("bootstrap backend start requested");
// Bring up the WSL backend if the user previously enabled it. The
// primary is already starting; reconcile fires off the WSL register
// in parallel rather than blocking primary readiness on a possibly
// slow first wsl.exe spawn.
yield* Effect.forkScoped(wslBackend.reconcile);
}
}).pipe(Effect.withSpan("desktop.bootstrap"));

Expand Down Expand Up @@ -226,10 +234,20 @@ const scopedProgram = Effect.scoped(
yield* Effect.annotateCurrentSpan({ scope: "desktop", runId });

const shutdown = yield* DesktopLifecycle.DesktopShutdown;
const backendManager = yield* DesktopBackendManager.DesktopBackendManager;
const pool = yield* DesktopBackendPool.DesktopBackendPool;

yield* Effect.addFinalizer(() =>
backendManager.stop().pipe(Effect.ensuring(shutdown.markComplete)),
Effect.gen(function* () {
// Stop every backend in the pool, not just the primary. The
// electronApp.quit() path can race ahead of the layer-scope
// cascade, so leaving the WSL instance for its parent scope
// finalizer means it gets hard-killed by the OS instead of
// receiving SIGTERM + grace. Stops run concurrently.
const instances = yield* pool.list;
yield* Effect.forEach(instances, (instance) => instance.stop(), {
concurrency: "unbounded",
});
}).pipe(Effect.ensuring(shutdown.markComplete)),
);

yield* startup;
Expand Down
5 changes: 4 additions & 1 deletion apps/desktop/src/app/DesktopObservability.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,8 @@ describe("DesktopObservability", () => {
}).pipe(Effect.provide(environmentLayer));

yield* Effect.gen(function* () {
const outputLog = yield* DesktopObservability.DesktopBackendOutputLog;
const factory = yield* DesktopObservability.DesktopBackendOutputLogFactory;
const outputLog = yield* factory.forInstance("primary");
yield* outputLog.writeSessionBoundary({
phase: "START",
details: "pid=123 port=3773 cwd=/repo",
Expand All @@ -145,13 +146,15 @@ describe("DesktopObservability", () => {
assert.equal(boundary.level, "INFO");
assert.equal(boundary.annotations.component, "desktop-backend-child");
assert.equal(boundary.annotations.runId, "test-run");
assert.equal(boundary.annotations.instanceId, "primary");
assert.equal(boundary.annotations.phase, "START");
assert.equal(boundary.annotations.details, "pid=123 port=3773 cwd=/repo");

assert.equal(output.message, "backend child process output");
assert.equal(output.level, "INFO");
assert.equal(output.annotations.component, "desktop-backend-child");
assert.equal(output.annotations.runId, "test-run");
assert.equal(output.annotations.instanceId, "primary");
assert.equal(output.annotations.stream, "stdout");
assert.equal(output.annotations.text, "hello server\n");
}).pipe(
Expand Down
101 changes: 91 additions & 10 deletions apps/desktop/src/app/DesktopObservability.ts
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@ import * as PlatformError from "effect/PlatformError";
import * as References from "effect/References";
import * as Ref from "effect/Ref";
import * as Schema from "effect/Schema";
import * as Scope from "effect/Scope";
import * as Semaphore from "effect/Semaphore";
import * as SynchronizedRef from "effect/SynchronizedRef";
import * as Tracer from "effect/Tracer";
import { OtlpSerialization, OtlpTracer } from "effect/unstable/observability";

Expand All @@ -40,10 +42,23 @@ export interface DesktopBackendOutputLogShape {
) => Effect.Effect<void>;
}

export class DesktopBackendOutputLog extends Context.Service<
DesktopBackendOutputLog,
DesktopBackendOutputLogShape
>()("t3/desktop/BackendOutputLog") {}
// Factory for per-instance backend output logs. `forInstance(id)` returns
// a writer that targets a distinct rotating log file — the primary
// instance keeps `server-child.log` so the historical path stays stable
// for ops; other instances get `server-child-<sanitized-id>.log`.
//
// Writers are cached per id within a single factory instance so repeated
// `forInstance` calls (e.g. during a backend restart that re-resolves
// services) reuse the same rotating writer rather than racing each other
// on the same file.
export interface DesktopBackendOutputLogFactoryShape {
readonly forInstance: (id: string) => Effect.Effect<DesktopBackendOutputLogShape>;
}

export class DesktopBackendOutputLogFactory extends Context.Service<
DesktopBackendOutputLogFactory,
DesktopBackendOutputLogFactoryShape
>()("t3/desktop/BackendOutputLogFactory") {}

const textEncoder = new TextEncoder();
const textDecoder = new TextDecoder();
Expand Down Expand Up @@ -293,13 +308,34 @@ const writeBackendChildLogRecord = Effect.fn("desktop.observability.writeBackend
},
);

const backendOutputLogLayer = Layer.effect(
DesktopBackendOutputLog,
Effect.gen(function* () {
const environment = yield* DesktopEnvironment.DesktopEnvironment;
const PRIMARY_BACKEND_LOG_INSTANCE_ID = "primary";

const sanitizeInstanceIdForFileName = (id: string): string => id.replace(/[^a-zA-Z0-9._-]+/g, "_");

const backendLogFilePathForInstance = (
environment: DesktopEnvironment.DesktopEnvironmentShape,
id: string,
): string => {
// Primary keeps the historical "server-child.log" path so ops scripts
// and packaged-build log inspection still find it where it always lived.
if (id === PRIMARY_BACKEND_LOG_INSTANCE_ID) {
return environment.path.join(environment.logDir, "server-child.log");
}
const sanitized = sanitizeInstanceIdForFileName(id);
return environment.path.join(environment.logDir, `server-child-${sanitized}.log`);
};

const makeBackendOutputLogForInstance = (
environment: DesktopEnvironment.DesktopEnvironmentShape,
id: string,
): Effect.Effect<
DesktopBackendOutputLogShape,
never,
FileSystem.FileSystem | Path.Path | Scope.Scope
> =>
Effect.gen(function* () {
const writer = yield* makeRotatingLogFileWriter({
filePath: environment.path.join(environment.logDir, "server-child.log"),
filePath: backendLogFilePathForInstance(environment, id),
}).pipe(Effect.option);

return Option.match(writer, {
Expand All @@ -316,6 +352,7 @@ const backendOutputLogLayer = Layer.effect(
annotations: {
component: "desktop-backend-child",
runId,
instanceId: id,
phase,
details: sanitizeLogValue(details),
},
Expand All @@ -333,6 +370,7 @@ const backendOutputLogLayer = Layer.effect(
annotations: {
component: "desktop-backend-child",
runId,
instanceId: id,
stream: streamName,
text: textDecoder.decode(chunk),
},
Expand All @@ -341,6 +379,49 @@ const backendOutputLogLayer = Layer.effect(
),
}) satisfies DesktopBackendOutputLogShape,
});
});

const backendOutputLogFactoryLayer = Layer.effect(
DesktopBackendOutputLogFactory,
Effect.gen(function* () {
const environment = yield* DesktopEnvironment.DesktopEnvironment;
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const factoryScope = yield* Scope.Scope;
// Per-id cache so repeated forInstance(id) calls reuse the same
// rotating writer instead of opening a second handle on the same
// file. Each writer pins itself to the factory's scope so all log
// resources tear down together at app exit. Mutex serializes
// concurrent first-time lookups for the same id.
const cacheRef = yield* SynchronizedRef.make<ReadonlyMap<string, DesktopBackendOutputLogShape>>(
new Map(),
);

const makeForId = (id: string): Effect.Effect<DesktopBackendOutputLogShape> =>
SynchronizedRef.modifyEffect(cacheRef, (cache) => {
// Key the cache by the resolved file path, not the raw id.
// Otherwise two ids that sanitize to the same filename (e.g.
// `wsl:default` and `wsl_default`) would each create their
// own RotatingLogFileWriter pointing at the same file, with
// independent currentSize tracking and a race on writes.
const cacheKey = backendLogFilePathForInstance(environment, id);
const cached = cache.get(cacheKey);
if (cached !== undefined) return Effect.succeed([cached, cache] as const);
return makeBackendOutputLogForInstance(environment, id).pipe(
Effect.provideService(FileSystem.FileSystem, fileSystem),
Effect.provideService(Path.Path, path),
Scope.provide(factoryScope),
Effect.map((shape) => {
const next = new Map(cache);
next.set(cacheKey, shape);
return [shape, next as ReadonlyMap<string, DesktopBackendOutputLogShape>] as const;
}),
);
});

return DesktopBackendOutputLogFactory.of({
forInstance: (id) => makeForId(id),
});
}),
);

Expand Down Expand Up @@ -387,7 +468,7 @@ const tracerLayer = Layer.unwrap(
).pipe(Layer.provideMerge(OtlpSerialization.layerJson));

export const layer = Layer.mergeAll(
backendOutputLogLayer,
backendOutputLogFactoryLayer,
desktopLoggerLayer,
tracerLayer,
Layer.succeed(Tracer.MinimumTraceLevel, "Info"),
Expand Down
2 changes: 0 additions & 2 deletions apps/desktop/src/app/DesktopState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import * as Layer from "effect/Layer";
import * as Ref from "effect/Ref";

export interface DesktopStateShape {
readonly backendReady: Ref.Ref<boolean>;
readonly quitting: Ref.Ref<boolean>;
}

Expand All @@ -15,7 +14,6 @@ export class DesktopState extends Context.Service<DesktopState, DesktopStateShap
export const layer = Layer.effect(
DesktopState,
Effect.all({
backendReady: Ref.make(false),
quitting: Ref.make(false),
}),
);
Loading
Loading