Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
17 changes: 9 additions & 8 deletions apps/desktop/src/backend/DesktopBackendConfiguration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -447,8 +447,8 @@ const resolveWslStartConfig = Effect.fn("desktop.backendConfiguration.resolveWsl
noBrowser: true,
port: input.port,
// Omit t3Home so the Linux backend uses its own home dir instead of
// the Windows-side baseDir (which would be a /mnt/c path and share
// the SQLite file with the primary).
// the Windows-side baseDir, which would share the primary's SQLite file
// through the Windows filesystem mount.
host: wslBindHost,
desktopBootstrapToken: input.bootstrapToken,
// PortSchema rejects 0, so when tailscale serve is disabled we still
Expand All @@ -468,9 +468,10 @@ const resolveWslStartConfig = Effect.fn("desktop.backendConfiguration.resolveWsl
// archive FILE. The Windows primary reads its entry through
// ELECTRON_RUN_AS_NODE (asar-aware), but the WSL backend launches plain
// `wsl.exe -- node`, which can't read inside an asar. electron-builder unpacks
// the server bundle + node-pty (see asarUnpack in build-desktop-artifact.ts)
// to the app.asar.unpacked sibling, so point WSL there. In dev appRoot is
// already a real directory, so this is a no-op.
// the server bundle and intentionally external runtime dependencies (see
// asarUnpack in build-desktop-artifact.ts) to the app.asar.unpacked sibling,
// so point WSL there. In dev appRoot is already a real directory, so this is
// a no-op.
const wslAppRoot = environment.isPackaged
? environment.path.join(environment.resourcesPath, "app.asar.unpacked")
: environment.appRoot;
Expand Down Expand Up @@ -522,9 +523,9 @@ const resolveWslStartConfig = Effect.fn("desktop.backendConfiguration.resolveWsl

// Build an explicit copy of process.env minus T3CODE_HOME (dev-runner
// exports the Windows-side base dir for the primary; if it leaks into
// the WSL backend the Linux side ends up sharing C:\Users\...\.t3 via
// /mnt/c, which means both backends read/write the same database and
// their env-ids collide).
// the WSL backend the Linux side reaches the same .t3 directory through
// the Windows filesystem mount, so both backends write the same database
// and their environment IDs collide).
const parentEnvWithoutT3Home: Record<string, string | undefined> = {};
for (const [key, value] of Object.entries(process.env)) {
if (key === "T3CODE_HOME") continue;
Expand Down
68 changes: 52 additions & 16 deletions apps/desktop/src/backend/DesktopBackendManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@

import * as Brand from "effect/Brand";
import * as Cause from "effect/Cause";
import * as Clock from "effect/Clock";
import * as Duration from "effect/Duration";
import * as Effect from "effect/Effect";
import * as Exit from "effect/Exit";
Expand Down Expand Up @@ -89,7 +90,7 @@ export interface DesktopBackendStartConfig extends BackendProcessContext {
readonly env: Record<string, string | undefined>;
// When true the spawner merges the desktop process.env on top of `env`;
// when false `env` is passed verbatim. WSL mode opts out so a leaking
// T3CODE_HOME can't pin the WSL backend to /mnt/c/...\.t3.
// T3CODE_HOME can't pin the WSL backend to a Windows-mounted .t3 directory.
readonly extendEnv: boolean;
readonly bootstrap: DesktopBackendBootstrapValue;
readonly bootstrapDelivery: DesktopBackendBootstrapDelivery;
Expand Down Expand Up @@ -636,8 +637,11 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* (
const state = yield* Ref.make(initialState);
const mutex = yield* Semaphore.make(1);

const { logWarning: logInstanceWarning, logError: logInstanceError } =
DesktopObservability.makeComponentLogger(`desktop-backend-instance:${spec.id}`);
const {
logInfo: logInstanceInfo,
logWarning: logInstanceWarning,
logError: logInstanceError,
} = DesktopObservability.makeComponentLogger(`desktop-backend-instance:${spec.id}`);

const updateActiveRun = (runId: number, f: (run: ActiveBackendRun) => ActiveBackendRun) =>
Ref.update(state, withActiveRun(runId, f));
Expand Down Expand Up @@ -886,6 +890,11 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* (
);
});

const runStartedAt = yield* Clock.currentTimeNanos;
const elapsedMs = Clock.currentTimeNanos.pipe(
Effect.map((now) => Duration.toMillis(Duration.nanos(now - runStartedAt))),
);

const program = runBackendProcess({
...config.value,
desktopTelemetryStream: desktopTelemetryPublisher.encoded,
Expand All @@ -896,6 +905,15 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* (
...run,
pid: Option.some(pid),
}));
yield* logInstanceInfo("backend process spawned", {
pid,
port: config.value.bootstrap.port,
cwd: config.value.cwd,
entryPath: config.value.entryPath,
executablePath: config.value.executablePath,
bootstrapDelivery: config.value.bootstrapDelivery,
elapsedMs: yield* elapsedMs,
});
yield* backendOutputLog.beginSession({
details: `pid=${pid} port=${config.value.bootstrap.port} cwd=${config.value.cwd}`,
});
Expand All @@ -906,31 +924,49 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* (
exitObserved: true,
})),
onReady: Effect.fn("desktop.backendInstance.onReady")(function* () {
const isCurrentRun = yield* Ref.modify(state, (latest) => {
const activeRun = Option.getOrUndefined(latest.active);
if (activeRun?.id !== runId) {
return [false, latest] as const;
}

return [
true,
const { isCurrentRun, pid } = yield* Ref.modify(
state,
(
latest,
): readonly [
{
...latest,
restartAttempt: 0,
ready: true,
readonly isCurrentRun: boolean;
readonly pid: Option.Option<number>;
},
] as const;
});
BackendManagerState,
] => {
const activeRun = Option.getOrUndefined(latest.active);
if (activeRun?.id !== runId) {
return [{ isCurrentRun: false, pid: Option.none<number>() }, latest] as const;
}

return [
{ isCurrentRun: true, pid: activeRun.pid },
{
...latest,
restartAttempt: 0,
ready: true,
},
] as const;
},
);
if (!isCurrentRun) {
return;
}

yield* logInstanceInfo("backend readiness succeeded", {
pid: Option.getOrUndefined(pid),
httpBaseUrl: config.value.httpBaseUrl.href,
elapsedMs: yield* elapsedMs,
});
yield* spec.onReady?.(config.value.httpBaseUrl) ?? Effect.void;
}),
onReadinessFailure: Effect.fn("desktop.backendInstance.onReadinessFailure")(
function* (error) {
yield* logInstanceWarning("backend readiness check failed during bootstrap", {
error: error.message,
httpBaseUrl: config.value.httpBaseUrl.href,
elapsedMs: yield* elapsedMs,
});
yield* backendOutputLog.persistFailureSnapshot({
details: error.message,
Expand Down
23 changes: 10 additions & 13 deletions apps/desktop/src/wsl/DesktopWslEnvironment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -229,14 +229,12 @@ const NODE_PTY_PROBE_SCRIPT = (
printf 'nodeVersion:%s\\n' "$(node -p 'process.versions.node' 2>/dev/null)"
printf 'resolvedPath:%s\\n' "$PATH"
cd ${shellQuote(linuxServerDir)} && node <<'NODE' >/dev/null 2>&1
// The server bundle externalizes its deps to node_modules, and the WSL Node
// can't read inside app.asar, so confirm those deps are unpacked on the real
// filesystem before reporting the backend healthy. "effect" is the framework
// every server module imports; resolving it validates the whole node_modules
// tree. Exit 3 marks this distinct from a node-pty problem so the caller can
// report it accurately instead of letting the server crash on
// ERR_MODULE_NOT_FOUND at launch (which, in wsl-only mode, would just fail to
// launch with no fallback).
// The pure-JS startup graph is bundled, but native and asset-owning runtime
// dependencies remain in node_modules. WSL Node can't read inside app.asar, so
// confirm the unpacked production dependency tree is reachable before reporting
// the backend healthy. "effect" is a stable direct dependency and inexpensive
// resolution sentinel; node-pty is loaded and validated below. Exit 3 marks a
// missing dependency tree distinctly from a node-pty compatibility problem.
try { require.resolve("effect"); } catch (_e) { process.exit(3); }
const fs = require("node:fs");
const path = require("node:path");
Expand Down Expand Up @@ -462,11 +460,10 @@ const ensureNodePtyImpl = (
} as const;
}

// Server dependencies (e.g. "effect") couldn't be resolved on the WSL
// filesystem — a packaging regression, since the server bundle needs its
// node_modules unpacked from the asar. Fatal so wsl-only mode falls back to
// Windows and dual mode surfaces the reason inline, instead of the server
// crash-looping on ERR_MODULE_NOT_FOUND once it actually launches.
// The external production dependency tree couldn't be resolved on the WSL
// filesystem. This is a packaging regression: intentionally external native
// and asset-owning packages must be unpacked from the asar. Fatal so wsl-only
// mode falls back to Windows and dual mode surfaces the reason inline.
if (probe.exitCode === 3) {
return {
ok: false,
Expand Down
10 changes: 7 additions & 3 deletions apps/server/src/persistence/Migrations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,8 @@ export const makeMigrationLoader = (throughId?: number) =>
*/
const run = Migrator.make({});

const processUptimeMs = () => Math.round(process.uptime() * 1000);

export interface RunMigrationsOptions {
readonly toMigrationInclusive?: number | undefined;
}
Expand All @@ -143,9 +145,11 @@ export const runMigrations = Effect.fn("runMigrations")(function* ({
}: RunMigrationsOptions = {}) {
const executedMigrations = yield* run({ loader: makeMigrationLoader(toMigrationInclusive) });
const migrations = executedMigrations.map(([id, name]) => `${id}_${name}`);
yield* migrations.length === 0
? Effect.logDebug("Database schema is current")
: Effect.log("Migrations ran successfully").pipe(Effect.annotateLogs({ migrations }));
const migrationLog =
migrations.length === 0
? Effect.logDebug("Database schema is current")
: Effect.log("Migrations ran successfully").pipe(Effect.annotateLogs({ migrations }));
yield* migrationLog.pipe(Effect.annotateLogs({ processUptimeMs: processUptimeMs() }));
return executedMigrations;
});

Expand Down
10 changes: 9 additions & 1 deletion apps/server/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,8 @@ const ApplicationObservabilityLive = ObservabilityLive.pipe(
Layer.provideMerge(ResourceAttributionLayerLive),
);

const processUptimeMs = () => Math.round(process.uptime() * 1000);

const PtyAdapterLive = Layer.unwrap(
Effect.gen(function* () {
if (typeof Bun !== "undefined") {
Expand Down Expand Up @@ -482,7 +484,13 @@ export const makeServerLayer = Layer.unwrap(

const httpListeningLayer = Layer.effectDiscard(
Effect.gen(function* () {
yield* HttpServer.HttpServer;
const server = yield* HttpServer.HttpServer;
const address = server.address;
yield* Effect.logInfo("HTTP server listening", {
address: HttpServer.formatAddress(address),
port: address._tag === "TcpAddress" ? address.port : undefined,
processUptimeMs: processUptimeMs(),
});
const startup = yield* ServerRuntimeStartup.ServerRuntimeStartup;
yield* startup.markHttpListening;
}),
Expand Down
26 changes: 26 additions & 0 deletions apps/server/vite.config.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { assert, describe, it } from "@effect/vitest";

import { shouldBundleCliDependency } from "./vite.config.ts";

describe("shouldBundleCliDependency", () => {
it("bundles direct dependencies and their subpaths", () => {
assert.isTrue(shouldBundleCliDependency("effect"));
assert.isTrue(shouldBundleCliDependency("effect/Effect"));
assert.isTrue(shouldBundleCliDependency("@opencode-ai/sdk/client"));
});

it("bundles workspace dependencies", () => {
assert.isTrue(shouldBundleCliDependency("@t3tools/shared/httpReadiness"));
assert.isTrue(shouldBundleCliDependency("effect-acp"));
});

it("keeps packages that need runtime files external", () => {
assert.isFalse(shouldBundleCliDependency("node-pty"));
assert.isFalse(shouldBundleCliDependency("@anthropic-ai/claude-agent-sdk/cli.js"));
assert.isFalse(shouldBundleCliDependency("node-pty/lib/index.js"));
});

it("does not bundle unknown packages", () => {
assert.isFalse(shouldBundleCliDependency("not-a-server-dependency"));
});
});
39 changes: 38 additions & 1 deletion apps/server/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,45 @@ const bundledPackagePrefixes = [
"effect-codex-app-server",
];

// WSL loads the packaged CLI through a Windows filesystem mount resolved by
// wslpath (commonly /mnt/c). Resolving a large dependency graph there is
// substantially slower than reading a few bundled chunks. Bundle direct runtime
// dependencies by default so adding a normal JS dependency cannot silently
// regress startup. Exceptions own native binaries, target another runtime, or
// resolve package assets at runtime and must stay in node_modules.
const externalRuntimePackageNames = new Set([
// Resolves its CLI and other files from the installed package.
"@anthropic-ai/claude-agent-sdk",

// Loaded only when the server runs under Bun.
"@effect/platform-bun",
"@effect/sql-sqlite-bun",

// Load platform-specific native binaries from node_modules.
"@ff-labs/fff-node",
"node-pty",
]);
const runtimePackageNames = new Set(Object.keys(packageJson.dependencies));

function packageNameFromId(id: string): string {
if (id.startsWith("@")) {
const [scope, name] = id.split("/");
return scope && name ? `${scope}/${name}` : id;
}

return id.split("/")[0] ?? id;
}

export function shouldBundleCliDependency(id: string): boolean {
return bundledPackagePrefixes.some((prefix) => id.startsWith(prefix));
const packageName = packageNameFromId(id);
if (externalRuntimePackageNames.has(packageName)) {
return false;
}

return (
runtimePackageNames.has(packageName) ||
bundledPackagePrefixes.some((prefix) => id.startsWith(prefix))
);
}

const repoEnv = loadRepoEnv();
Expand Down
Loading