Skip to content
Merged
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: 10 additions & 7 deletions apps/server/src/cli/claudeHistory.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
import * as Effect from "effect/Effect";
import { Argument, Command } from "effect/cli";

import { runClaudeHistoryWorker } from "../claudeHistoryWorker.ts";

/** Single-executable builds lack Node, so they use this subcommand instead of claudeHistoryWorker.mjs. */
export const claudeHistoryCommand = Command.make("__claude-history", {
method: Argument.String("method"),
Expand All @@ -11,11 +9,16 @@ export const claudeHistoryCommand = Command.make("__claude-history", {
}).pipe(
Command.unlisted,
Command.withHandler(({ method, sessionId, options }) =>
Effect.promise(() =>
runClaudeHistoryWorker(
method,
sessionId,
options._tag === "Some" ? options.value : undefined,
// Two effects, so an interrupt during the import never starts the worker.
Effect.promise(() => import("../claudeHistoryWorker.ts")).pipe(
Effect.flatMap(({ runClaudeHistoryWorker }) =>
Effect.promise(() =>
runClaudeHistoryWorker(
method,
sessionId,
options._tag === "Some" ? options.value : undefined,
),
),
),
),
),
Expand Down
167 changes: 167 additions & 0 deletions apps/server/src/cli/deferredImports.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
// @effect-diagnostics nodeBuiltinImport:off - CLI integration uses temporary Node paths.
import * as NodePath from "node:path";

import * as NodeServices from "@effect/platform-node/NodeServices";
import { assert, it } from "@effect/vitest";
import * as NetService from "@t3tools/shared/Net";
import * as Effect from "effect/Effect";
import * as Exit from "effect/Exit";
import * as Fiber from "effect/Fiber";
import * as FileSystem from "effect/FileSystem";
import * as Layer from "effect/Layer";
import { Command } from "effect/cli";
import { FetchHttpClient } from "effect/http";
import { vi } from "vite-plus/test";

import { cli } from "../binCli.ts";
import {
makePersistedServerRuntimeState,
persistServerRuntimeState,
} from "../serverRuntimeState.ts";

const hooks = vi.hoisted(() => ({
importsAttempted: [] as string[],
historyImportStarted: Promise.withResolvers<void>(),
releaseHistoryImport: Promise.withResolvers<void>(),
runClaudeHistoryWorker: vi.fn(async () => {}),
}));

vi.mock("../server.ts", () => {
hooks.importsAttempted.push("server");
throw new Error("server import failed");
});
vi.mock("../orchestration-v2/runtimeLayer.ts", () => {
hooks.importsAttempted.push("runtimeLayer");
throw new Error("runtime import failed");
});
vi.mock("../claudeHistoryWorker.ts", async () => {
hooks.importsAttempted.push("claudeHistoryWorker");
hooks.historyImportStarted.resolve();
await hooks.releaseHistoryImport.promise;
return { runClaudeHistoryWorker: hooks.runClaudeHistoryWorker };
});

const layerCliRuntime = Layer.mergeAll(NodeServices.layer, NetService.layer);
const runCli = (args: ReadonlyArray<string>) =>
Command.runWith(cli, { version: "0.0.0" })(args).pipe(Effect.provide(layerCliRuntime));

it.layer(NodeServices.layer)("CLI deferred imports", (it) => {
it.effect("runs commands that do not need them without loading them", () =>
Effect.gen(function* () {
hooks.importsAttempted.length = 0;
for (const args of [["--version"], ["--help"], ["project", "--help"], ["thread", "--help"]]) {
yield* runCli(args);
}
assert.deepEqual(hooks.importsAttempted, []);
}),
);

it.effect("fails a server start on a broken import before resolving config", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-deferred-server-" });
const baseDir = NodePath.join(root, "state");

for (const command of ["serve", "start"]) {
const exit = yield* runCli([command, "--base-dir", baseDir]).pipe(Effect.exit);
assert.isTrue(Exit.hasDies(exit));
}

assert.isFalse(yield* fs.exists(baseDir));
}),
);

it.effect("keeps server state when the offline project runtime fails to import", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-deferred-project-" });
const baseDir = NodePath.join(root, "state");
const runtimeStatePath = NodePath.join(baseDir, "userdata", "server-runtime.json");
yield* persistServerRuntimeState({
path: runtimeStatePath,
state: yield* makePersistedServerRuntimeState({
config: { host: "127.0.0.1", devUrl: undefined },
port: 3_773,
}),
});
const unreachable: typeof globalThis.fetch = () =>
Promise.reject(new Error("connection refused"));

const exit = yield* runCli(["project", "add", root, "--base-dir", baseDir]).pipe(
Effect.provideService(FetchHttpClient.Fetch, unreachable),
Effect.exit,
);

assert.isTrue(Exit.hasDies(exit));
assert.isTrue(yield* fs.exists(runtimeStatePath));
assert.isFalse(yield* fs.exists(NodePath.join(baseDir, "userdata", "server-owner.sqlite")));
}),
);

it.effect("forwards live project mutations without importing the offline runtime", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-deferred-live-project-" });
const baseDir = NodePath.join(root, "state");
const workspaceRoot = NodePath.join(root, "workspace");
yield* fs.makeDirectory(workspaceRoot);
yield* persistServerRuntimeState({
path: NodePath.join(baseDir, "userdata", "server-runtime.json"),
state: yield* makePersistedServerRuntimeState({
config: { host: "127.0.0.1", devUrl: undefined },
port: 3_773,
}),
});

hooks.importsAttempted.length = 0;
let mutations = 0;
const fetch: typeof globalThis.fetch = async (_input, init) => {
const isMutation = init?.method === "POST";
if (isMutation) mutations++;
return Response.json(
isMutation
? {
id: "live-project",
title: NodePath.basename(workspaceRoot),
workspaceRoot,
defaultModelSelection: null,
scripts: [],
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
deletedAt: null,
}
: { projects: [], updatedAt: "2026-01-01T00:00:00.000Z" },
);
};

const exit = yield* runCli(["project", "add", workspaceRoot, "--base-dir", baseDir]).pipe(
Effect.provideService(FetchHttpClient.Fetch, fetch),
Effect.exit,
);

assert.isTrue(Exit.isSuccess(exit));
assert.equal(mutations, 1);
assert.deepEqual(hooks.importsAttempted, []);
}),
);

it.effect("never starts the history worker after an interrupted import", () =>
Effect.gen(function* () {
const history = yield* runCli(["__claude-history", "getSessionMessages", "session"]).pipe(
Effect.forkChild,
);
yield* Effect.promise(() => hooks.historyImportStarted.promise);
yield* Fiber.interrupt(history);
hooks.releaseHistoryImport.resolve();
yield* Effect.promise(() => import("../claudeHistoryWorker.ts"));
assert.equal(hooks.runClaudeHistoryWorker.mock.calls.length, 0);

yield* runCli(["__claude-history", "getSessionMessages", "session"]);
yield* runCli(["__claude-history", "forkSession", "session", '{"dir":"/repo"}']);
assert.deepEqual(hooks.runClaudeHistoryWorker.mock.calls, [
["getSessionMessages", "session", undefined],
["forkSession", "session", '{"dir":"/repo"}'],
]);
}),
);
});
39 changes: 22 additions & 17 deletions apps/server/src/cli/project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ import * as ServerConfig from "../config.ts";
import * as SqlitePersistence from "../persistence/Sqlite.ts";
import * as ProcessRunner from "../processRunner.ts";
import * as ServerOwnership from "../serverOwnership.ts";
import * as RuntimeLayer from "../orchestration-v2/runtimeLayer.ts";
import type * as RuntimeLayer from "../orchestration-v2/runtimeLayer.ts";
import * as ProjectEnrichmentService from "../project/ProjectEnrichmentService.ts";
import * as ProjectFaviconResolver from "../project/ProjectFaviconResolver.ts";
import * as RepositoryIdentityResolver from "../project/RepositoryIdentityResolver.ts";
Expand Down Expand Up @@ -201,23 +201,24 @@ const projectCommandUuid = Crypto.Crypto.pipe(
),
);

const layerProjectCliRuntime = RuntimeLayer.layerProjectService.pipe(
Layer.provideMerge(ProjectEnrichmentService.layer),
Layer.provideMerge(RepositoryIdentityResolver.layer),
Layer.provideMerge(
ProjectFaviconResolver.layer.pipe(
Layer.provide(WorkspacePaths.layer),
Layer.provide(T3ProjectFileLoader.layer),
const makeProjectCliRuntimeLayer = (layerProjectService: typeof RuntimeLayer.layerProjectService) =>
layerProjectService.pipe(
Layer.provideMerge(ProjectEnrichmentService.layer),
Layer.provideMerge(RepositoryIdentityResolver.layer),
Layer.provideMerge(
ProjectFaviconResolver.layer.pipe(
Layer.provide(WorkspacePaths.layer),
Layer.provide(T3ProjectFileLoader.layer),
),
),
),
Layer.provideMerge(WorkspacePaths.layer),
Layer.provideMerge(
ServerSettings.layer.pipe(
Layer.provide(Layer.merge(ServerSecretStore.layer, ManagedPolicy.layer)),
Layer.provideMerge(WorkspacePaths.layer),
Layer.provideMerge(
ServerSettings.layer.pipe(
Layer.provide(Layer.merge(ServerSecretStore.layer, ManagedPolicy.layer)),
),
),
),
Layer.provideMerge(SqlitePersistence.layerConfig),
);
Layer.provideMerge(SqlitePersistence.layerConfig),
);

const PROJECT_CLI_LIVE_SERVER_PROBE_TIMEOUT = Duration.seconds(1);
const PROJECT_CLI_LIVE_SERVER_REQUEST_TIMEOUT = Duration.seconds(10);
Expand Down Expand Up @@ -426,7 +427,11 @@ const runProjectMutation = Effect.fn("runProjectMutation")(function* (
);
}

const layerOfflineRuntime = layerProjectCliRuntime.pipe(
// Before ownership and clearing, so a failed import leaves server state alone.
const { layerProjectService } = yield* Effect.promise(
() => import("../orchestration-v2/runtimeLayer.ts"),
);
const layerOfflineRuntime = makeProjectCliRuntimeLayer(layerProjectService).pipe(
Layer.provide(ServerConfig.layer(config)),
Layer.provide(Layer.succeed(References.MinimumLogLevel, minimumLogLevel)),
);
Expand Down
3 changes: 2 additions & 1 deletion apps/server/src/cli/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import * as CliError from "effect/cli/CliError";

import * as ServerConfig from "../config.ts";
import { awaitLauncherLoss } from "../launcherWatch.ts";
import { runServer } from "../server.ts";
import { type CliServerFlags, resolveServerConfig, sharedServerCommandFlags } from "./config.ts";

const encodeCommand = Schema.encodeEffect(Schema.fromJsonString(Schema.String));
Expand All @@ -28,6 +27,8 @@ const runServerCommand = Effect.fn("server.runServerCommand")(function* (
readonly rejectRunningServer?: boolean;
},
) {
// Before config resolution, which claims a port and creates directories.
const { runServer } = yield* Effect.promise(() => import("../server.ts"));
const logLevel = yield* GlobalFlag.LogLevel;
const config = yield* resolveServerConfig(flags, logLevel, options);
const run = runServer.pipe(Effect.provideService(ServerConfig.ServerConfig, config));
Expand Down
Loading