diff --git a/apps/server/integration/OrchestrationEngineHarness.integration.ts b/apps/server/integration/OrchestrationEngineHarness.integration.ts
index fa388ba052f..292b267e124 100644
--- a/apps/server/integration/OrchestrationEngineHarness.integration.ts
+++ b/apps/server/integration/OrchestrationEngineHarness.integration.ts
@@ -46,7 +46,7 @@ import {
import { ProviderService } from "../src/provider/Services/ProviderService.ts";
import { AnalyticsService } from "../src/telemetry/Services/AnalyticsService.ts";
import { CheckpointReactorLive } from "../src/orchestration/Layers/CheckpointReactor.ts";
-import { RepositoryIdentityResolverLive } from "../src/project/Layers/RepositoryIdentityResolver.ts";
+import * as RepositoryIdentityResolver from "../src/project/RepositoryIdentityResolver.ts";
import { OrchestrationEngineLive } from "../src/orchestration/Layers/OrchestrationEngine.ts";
import { OrchestrationProjectionPipelineLive } from "../src/orchestration/Layers/ProjectionPipeline.ts";
import { OrchestrationProjectionSnapshotQueryLive } from "../src/orchestration/Layers/ProjectionSnapshotQuery.ts";
@@ -72,7 +72,7 @@ import {
} from "./TestProviderAdapter.integration.ts";
import { deriveServerPaths, ServerConfig } from "../src/config.ts";
import * as WorkspaceEntries from "../src/workspace/WorkspaceEntries.ts";
-import { WorkspacePathsLive } from "../src/workspace/Layers/WorkspacePaths.ts";
+import * as WorkspacePaths from "../src/workspace/WorkspacePaths.ts";
import * as VcsDriverRegistry from "../src/vcs/VcsDriverRegistry.ts";
import { VcsStatusBroadcaster } from "../src/vcs/VcsStatusBroadcaster.ts";
import { GitWorkflowService } from "../src/git/GitWorkflowService.ts";
@@ -348,12 +348,12 @@ export const makeOrchestrationIntegrationHarness = (
),
Layer.provideMerge(
WorkspaceEntries.layer.pipe(
- Layer.provide(WorkspacePathsLive),
+ Layer.provide(WorkspacePaths.layer),
Layer.provideMerge(VcsDriverRegistry.layer),
Layer.provide(NodeServices.layer),
),
),
- Layer.provideMerge(WorkspacePathsLive),
+ Layer.provideMerge(WorkspacePaths.layer),
Layer.provideMerge(VcsProcess.layer),
);
const orchestrationReactorLayer = OrchestrationReactorLive.pipe(
@@ -378,7 +378,7 @@ export const makeOrchestrationIntegrationHarness = (
Layer.provideMerge(orchestrationReactorLayer),
Layer.provideMerge(providerRegistryLayer),
Layer.provide(persistenceLayer),
- Layer.provideMerge(RepositoryIdentityResolverLive),
+ Layer.provideMerge(RepositoryIdentityResolver.layer),
Layer.provideMerge(ServerSettingsService.layerTest()),
Layer.provideMerge(ServerConfig.layerTest(workspaceDir, rootDir)),
Layer.provideMerge(NodeServices.layer),
diff --git a/apps/server/src/assets/AssetAccess.test.ts b/apps/server/src/assets/AssetAccess.test.ts
index 29b1db25118..0cbe9176582 100644
--- a/apps/server/src/assets/AssetAccess.test.ts
+++ b/apps/server/src/assets/AssetAccess.test.ts
@@ -7,18 +7,18 @@ import * as Layer from "effect/Layer";
import * as Path from "effect/Path";
import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
-import { ServerConfig } from "../config.ts";
-import { ProjectFaviconResolverLive } from "../project/Layers/ProjectFaviconResolver.ts";
-import { WorkspacePathsLive } from "../workspace/Layers/WorkspacePaths.ts";
+import * as ServerConfig from "../config.ts";
+import * as ProjectFaviconResolver from "../project/ProjectFaviconResolver.ts";
+import * as WorkspacePaths from "../workspace/WorkspacePaths.ts";
import { ASSET_ROUTE_PREFIX, issueAssetUrl, resolveAsset } from "./AssetAccess.ts";
-const configLayer = ServerConfig.layerTest(process.cwd(), {
+const configLayer = ServerConfig.ServerConfig.layerTest(process.cwd(), {
prefix: "t3-asset-access-test-",
});
const testLayer = Layer.mergeAll(
configLayer,
- WorkspacePathsLive,
- ProjectFaviconResolverLive.pipe(Layer.provide(WorkspacePathsLive)),
+ WorkspacePaths.layer,
+ ProjectFaviconResolver.layer.pipe(Layer.provide(WorkspacePaths.layer)),
ServerSecretStore.layer.pipe(Layer.provide(configLayer)),
).pipe(Layer.provideMerge(NodeServices.layer));
@@ -127,7 +127,7 @@ describe("AssetAccess", () => {
it.effect("issues exact attachment capabilities by attachment id", () =>
Effect.gen(function* () {
- const config = yield* ServerConfig;
+ const config = yield* ServerConfig.ServerConfig;
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const attachmentId = "thread-1-00000000-0000-4000-8000-000000000001";
diff --git a/apps/server/src/assets/AssetAccess.ts b/apps/server/src/assets/AssetAccess.ts
index ae5086e9735..cf3c40f57c7 100644
--- a/apps/server/src/assets/AssetAccess.ts
+++ b/apps/server/src/assets/AssetAccess.ts
@@ -22,8 +22,8 @@ import {
import { ServerSecretStore } from "../auth/ServerSecretStore.ts";
import { resolveAttachmentPathById } from "../attachmentStore.ts";
import { ServerConfig } from "../config.ts";
-import { ProjectFaviconResolver } from "../project/Services/ProjectFaviconResolver.ts";
-import { WorkspacePaths } from "../workspace/Services/WorkspacePaths.ts";
+import * as ProjectFaviconResolver from "../project/ProjectFaviconResolver.ts";
+import * as WorkspacePaths from "../workspace/WorkspacePaths.ts";
export const ASSET_ROUTE_PREFIX = "/api/assets";
export const FALLBACK_PROJECT_FAVICON_SVG = ``;
@@ -103,7 +103,7 @@ const failAccess = (message: string, cause?: unknown) =>
const resolveCanonicalWorkspaceFile = Effect.fn("AssetAccess.resolveCanonicalWorkspaceFile")(
function* (input: { readonly workspaceRoot: string; readonly relativePath: string }) {
const fileSystem = yield* FileSystem.FileSystem;
- const workspacePaths = yield* WorkspacePaths;
+ const workspacePaths = yield* WorkspacePaths.WorkspacePaths;
const resolved = yield* workspacePaths
.resolveRelativePathWithinRoot(input)
.pipe(Effect.orElseSucceed(() => null));
@@ -130,7 +130,7 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i
}) {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
- const workspacePaths = yield* WorkspacePaths;
+ const workspacePaths = yield* WorkspacePaths.WorkspacePaths;
const expiresAt = (yield* Clock.currentTimeMillis) + ASSET_TOKEN_TTL_MS;
let claims: AssetClaims;
let fileName: string;
@@ -202,7 +202,7 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i
const workspaceRoot = yield* workspacePaths
.normalizeWorkspaceRoot(input.resource.cwd)
.pipe(Effect.mapError((cause) => failAccess(cause.message, cause)));
- const faviconResolver = yield* ProjectFaviconResolver;
+ const faviconResolver = yield* ProjectFaviconResolver.ProjectFaviconResolver;
const faviconPath = yield* faviconResolver.resolvePath(workspaceRoot);
const relativePath = faviconPath ? path.relative(workspaceRoot, faviconPath) : null;
if (
diff --git a/apps/server/src/bin.test.ts b/apps/server/src/bin.test.ts
index 64d366468f9..d71bc83f94e 100644
--- a/apps/server/src/bin.test.ts
+++ b/apps/server/src/bin.test.ts
@@ -1,5 +1,5 @@
// @effect-diagnostics nodeBuiltinImport:off - CLI integration exercises Node HTTP and filesystem boundaries.
-import * as NodeHttp from "node:http";
+import { createServer } from "node:http";
import { existsSync, mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
@@ -25,12 +25,12 @@ import * as ProjectionSnapshotQuery from "./orchestration/Services/ProjectionSna
import { OrchestrationLayerLive } from "./orchestration/runtimeLayer.ts";
import { orchestrationHttpApiLayer } from "./orchestration/http.ts";
import { layerConfig as SqlitePersistenceLayerLive } from "./persistence/Layers/Sqlite.ts";
-import { RepositoryIdentityResolverLive } from "./project/Layers/RepositoryIdentityResolver.ts";
+import * as RepositoryIdentityResolver from "./project/RepositoryIdentityResolver.ts";
import {
makePersistedServerRuntimeState,
persistServerRuntimeState,
} from "./serverRuntimeState.ts";
-import { WorkspacePathsLive } from "./workspace/Layers/WorkspacePaths.ts";
+import * as WorkspacePaths from "./workspace/WorkspacePaths.ts";
import * as ServerSecretStore from "./auth/ServerSecretStore.ts";
import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts";
import { environmentAuthenticatedAuthLayer } from "./auth/http.ts";
@@ -90,10 +90,10 @@ const makeCliTestServerConfig = (baseDir: string) =>
const makeProjectPersistenceLayer = (config: ServerConfig.ServerConfig["Service"]) =>
Layer.mergeAll(
OrchestrationLayerLive.pipe(
- Layer.provideMerge(RepositoryIdentityResolverLive),
+ Layer.provideMerge(RepositoryIdentityResolver.layer),
Layer.provideMerge(SqlitePersistenceLayerLive),
),
- WorkspacePathsLive,
+ WorkspacePaths.layer,
).pipe(Layer.provideMerge(NodeServices.layer), Layer.provide(ServerConfig.layer(config)));
const readPersistedSnapshot = (baseDir: string) =>
@@ -124,7 +124,7 @@ const withLiveProjectCliServer = (baseDir: string, run: () => Effect.Ef
),
Layer.provideMerge(makeProjectPersistenceLayer(config)),
Layer.provideMerge(
- NodeHttpServer.layer(NodeHttp.createServer, {
+ NodeHttpServer.layer(createServer, {
host: "127.0.0.1",
port: 0,
}),
diff --git a/apps/server/src/cli/connect.ts b/apps/server/src/cli/connect.ts
index 51582965913..314680b0d80 100644
--- a/apps/server/src/cli/connect.ts
+++ b/apps/server/src/cli/connect.ts
@@ -32,8 +32,7 @@ import { CLOUD_LINKED_USER_ID, RELAY_URL_SECRET } from "../cloud/config.ts";
import { relayUrlConfig } from "../cloud/publicConfig.ts";
import { headlessRelayClientTracingLayer } from "../cloud/relayTracing.ts";
import * as ServerConfig from "../config.ts";
-import { ServerEnvironmentLive } from "../environment/Layers/ServerEnvironment.ts";
-import * as ServerEnvironment from "../environment/Services/ServerEnvironment.ts";
+import * as ServerEnvironment from "../environment/ServerEnvironment.ts";
import { readPersistedServerRuntimeState } from "../serverRuntimeState.ts";
import { projectLocationFlags, resolveCliAuthConfig } from "./config.ts";
@@ -301,7 +300,7 @@ const runCloudCommand = (
CliTokenManager.layer.pipe(Layer.provide(ServerSecretStore.layer)),
RelayClient.layerCloudflared({ baseDir: config.baseDir }),
EnvironmentAuth.runtimeLayer,
- ServerEnvironmentLive,
+ ServerEnvironment.layer,
headlessRelayClientTracingLayer,
).pipe(
Layer.provideMerge(FetchHttpClient.layer),
diff --git a/apps/server/src/cli/project.ts b/apps/server/src/cli/project.ts
index eec7f3f5541..d52d5b214d8 100644
--- a/apps/server/src/cli/project.ts
+++ b/apps/server/src/cli/project.ts
@@ -31,14 +31,13 @@ import * as OrchestrationEngine from "../orchestration/Services/OrchestrationEng
import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts";
import { OrchestrationLayerLive } from "../orchestration/runtimeLayer.ts";
import { layerConfig as SqlitePersistenceLayerLive } from "../persistence/Layers/Sqlite.ts";
-import { RepositoryIdentityResolverLive } from "../project/Layers/RepositoryIdentityResolver.ts";
+import * as RepositoryIdentityResolver from "../project/RepositoryIdentityResolver.ts";
import * as ServerRuntimeStartup from "../serverRuntimeStartup.ts";
import {
clearPersistedServerRuntimeState,
readPersistedServerRuntimeState,
} from "../serverRuntimeState.ts";
-import { WorkspacePathsLive } from "../workspace/Layers/WorkspacePaths.ts";
-import * as WorkspacePaths from "../workspace/Services/WorkspacePaths.ts";
+import * as WorkspacePaths from "../workspace/WorkspacePaths.ts";
import { type CliAuthLocationFlags, projectLocationFlags, resolveCliAuthConfig } from "./config.ts";
type ProjectMutationTarget = {
@@ -68,9 +67,9 @@ const projectCommandUuid = Crypto.Crypto.pipe(
);
const ProjectCliRuntimeLive = Layer.mergeAll(
- WorkspacePathsLive,
+ WorkspacePaths.layer,
OrchestrationLayerLive.pipe(
- Layer.provideMerge(RepositoryIdentityResolverLive),
+ Layer.provideMerge(RepositoryIdentityResolver.layer),
Layer.provideMerge(SqlitePersistenceLayerLive),
),
);
@@ -301,7 +300,7 @@ const runProjectMutation = Effect.fn("runProjectMutation")(function* (
}).pipe(Effect.provide(offlineRuntimeLayer));
}).pipe(
Effect.provide(
- Layer.mergeAll(EnvironmentAuth.runtimeLayer, WorkspacePathsLive).pipe(
+ Layer.mergeAll(EnvironmentAuth.runtimeLayer, WorkspacePaths.layer).pipe(
Layer.provideMerge(FetchHttpClient.layer),
Layer.provide(ServerConfig.layer(config)),
Layer.provide(Layer.succeed(References.MinimumLogLevel, minimumLogLevel)),
diff --git a/apps/server/src/cloud/http.test.ts b/apps/server/src/cloud/http.test.ts
index 3a8586f150a..58274c9d708 100644
--- a/apps/server/src/cloud/http.test.ts
+++ b/apps/server/src/cloud/http.test.ts
@@ -9,7 +9,7 @@ import { HttpClient, HttpServerRequest } from "effect/unstable/http";
import { RelayClientTracer } from "@t3tools/shared/relayTracing";
import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts";
import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
-import * as ServerEnvironment from "../environment/Services/ServerEnvironment.ts";
+import * as ServerEnvironment from "../environment/ServerEnvironment.ts";
import * as CliTokenManager from "./CliTokenManager.ts";
import { consumeCloudReplayGuards, reconcileDesiredCloudLink } from "./http.ts";
import * as ManagedEndpointRuntime from "./ManagedEndpointRuntime.ts";
diff --git a/apps/server/src/cloud/http.ts b/apps/server/src/cloud/http.ts
index 86716b69a35..64c87f3487c 100644
--- a/apps/server/src/cloud/http.ts
+++ b/apps/server/src/cloud/http.ts
@@ -1,4 +1,4 @@
-import * as NodeCrypto from "node:crypto";
+import { createPublicKey } from "node:crypto";
import {
AuthRelayReadScope,
AuthRelayWriteScope,
@@ -55,7 +55,7 @@ import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder";
import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts";
import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
import { requireEnvironmentScope } from "../auth/http.ts";
-import * as ServerEnvironment from "../environment/Services/ServerEnvironment.ts";
+import * as ServerEnvironment from "../environment/ServerEnvironment.ts";
import * as ManagedEndpointRuntime from "./ManagedEndpointRuntime.ts";
import {
CLOUD_ENDPOINT_RUNTIME_CONFIG,
@@ -152,7 +152,7 @@ function validateCloudMintPublicKey(
publicKey: string,
): Effect.Effect {
return Effect.try({
- try: () => NodeCrypto.createPublicKey(publicKey.replace(/\\n/g, "\n")),
+ try: () => createPublicKey(publicKey.replace(/\\n/g, "\n")),
catch: () =>
new EnvironmentHttpBadRequestError({
message: "Cloud mint public key must be a valid Ed25519 public key.",
diff --git a/apps/server/src/environment/Layers/ServerEnvironment.test.ts b/apps/server/src/environment/ServerEnvironment.test.ts
similarity index 90%
rename from apps/server/src/environment/Layers/ServerEnvironment.test.ts
rename to apps/server/src/environment/ServerEnvironment.test.ts
index 3bb96a83e1c..3b0cef13bf9 100644
--- a/apps/server/src/environment/Layers/ServerEnvironment.test.ts
+++ b/apps/server/src/environment/ServerEnvironment.test.ts
@@ -1,5 +1,5 @@
// @effect-diagnostics nodeBuiltinImport:off
-import * as nodePath from "node:path";
+import { dirname } from "node:path";
import * as NodeServices from "@effect/platform-node/NodeServices";
import { expect, it } from "@effect/vitest";
import * as Effect from "effect/Effect";
@@ -8,12 +8,11 @@ import * as FileSystem from "effect/FileSystem";
import * as Layer from "effect/Layer";
import * as PlatformError from "effect/PlatformError";
-import * as ServerConfig from "../../config.ts";
-import * as ServerEnvironment from "../Services/ServerEnvironment.ts";
-import { ServerEnvironmentLive } from "./ServerEnvironment.ts";
+import * as ServerConfig from "../config.ts";
+import * as ServerEnvironment from "./ServerEnvironment.ts";
const makeServerEnvironmentLayer = (baseDir: string) =>
- ServerEnvironmentLive.pipe(Layer.provide(ServerConfig.layerTest(process.cwd(), baseDir)));
+ ServerEnvironment.layer.pipe(Layer.provide(ServerConfig.layerTest(process.cwd(), baseDir)));
const makeServerConfig = Effect.fn(function* (baseDir: string) {
const derivedPaths = yield* ServerConfig.deriveServerPaths(baseDir, undefined);
@@ -77,7 +76,7 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => {
});
const serverConfig = yield* makeServerConfig(baseDir);
const environmentIdPath = serverConfig.environmentIdPath;
- yield* fileSystem.makeDirectory(nodePath.dirname(environmentIdPath), { recursive: true });
+ yield* fileSystem.makeDirectory(dirname(environmentIdPath), { recursive: true });
yield* fileSystem.writeFileString(environmentIdPath, "persisted-environment-id\n");
const writeAttempts: string[] = [];
const failingFileSystemLayer = FileSystem.layerNoop({
@@ -113,7 +112,7 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => {
return yield* serverEnvironment.getDescriptor;
}).pipe(
Effect.provide(
- ServerEnvironmentLive.pipe(
+ ServerEnvironment.layer.pipe(
Layer.provide(Layer.merge(ServerConfig.layer(serverConfig), failingFileSystemLayer)),
),
),
diff --git a/apps/server/src/environment/Layers/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts
similarity index 72%
rename from apps/server/src/environment/Layers/ServerEnvironment.ts
rename to apps/server/src/environment/ServerEnvironment.ts
index fd4f6baab1a..433a9d3f02a 100644
--- a/apps/server/src/environment/Layers/ServerEnvironment.ts
+++ b/apps/server/src/environment/ServerEnvironment.ts
@@ -1,17 +1,25 @@
import { EnvironmentId, type ExecutionEnvironmentDescriptor } from "@t3tools/contracts";
import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess";
+import * as Context from "effect/Context";
import * as Crypto from "effect/Crypto";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Layer from "effect/Layer";
import * as Path from "effect/Path";
-import { ServerConfig } from "../../config.ts";
-import { layer as ProcessRunnerLive } from "../../processRunner.ts";
-import { ServerEnvironment, type ServerEnvironmentShape } from "../Services/ServerEnvironment.ts";
-import packageJson from "../../../package.json" with { type: "json" };
+import packageJson from "../../package.json" with { type: "json" };
+import * as ServerConfig from "../config.ts";
+import * as ProcessRunner from "../processRunner.ts";
import { resolveServerEnvironmentLabel } from "./ServerEnvironmentLabel.ts";
+export class ServerEnvironment extends Context.Service<
+ ServerEnvironment,
+ {
+ readonly getEnvironmentId: Effect.Effect;
+ readonly getDescriptor: Effect.Effect;
+ }
+>()("t3/environment/ServerEnvironment") {}
+
function platformOs(platform: NodeJS.Platform): ExecutionEnvironmentDescriptor["platform"]["os"] {
switch (platform) {
case "darwin":
@@ -38,10 +46,10 @@ function platformArch(
}
}
-export const makeServerEnvironment = Effect.fn("makeServerEnvironment")(function* () {
+export const make = Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
- const serverConfig = yield* ServerConfig;
+ const serverConfig = yield* ServerConfig.ServerConfig;
const crypto = yield* Crypto.Crypto;
const hostPlatform = yield* HostProcessPlatform;
const hostArchitecture = yield* HostProcessArchitecture;
@@ -77,9 +85,7 @@ export const makeServerEnvironment = Effect.fn("makeServerEnvironment")(function
const environmentId = EnvironmentId.make(environmentIdRaw);
const cwdBaseName = path.basename(serverConfig.cwd).trim();
- const label = yield* resolveServerEnvironmentLabel({
- cwdBaseName,
- });
+ const label = yield* resolveServerEnvironmentLabel({ cwdBaseName });
const descriptor: ExecutionEnvironmentDescriptor = {
environmentId,
@@ -94,12 +100,15 @@ export const makeServerEnvironment = Effect.fn("makeServerEnvironment")(function
},
};
- return {
+ return ServerEnvironment.of({
getEnvironmentId: Effect.succeed(environmentId),
getDescriptor: Effect.succeed(descriptor),
- } satisfies ServerEnvironmentShape;
+ });
});
-export const ServerEnvironmentLive = Layer.effect(ServerEnvironment, makeServerEnvironment()).pipe(
- Layer.provide(ProcessRunnerLive),
-);
+/**
+ * ServerEnvironment is acquired from persisted filesystem and host-process
+ * state. It intentionally has no fallback Layer.succeed value: callers must
+ * provide the external platform services and a ServerConfig.
+ */
+export const layer = Layer.effect(ServerEnvironment, make).pipe(Layer.provide(ProcessRunner.layer));
diff --git a/apps/server/src/environment/Layers/ServerEnvironmentLabel.test.ts b/apps/server/src/environment/ServerEnvironmentLabel.test.ts
similarity index 97%
rename from apps/server/src/environment/Layers/ServerEnvironmentLabel.test.ts
rename to apps/server/src/environment/ServerEnvironmentLabel.test.ts
index 14580369a78..bc30bd0ce19 100644
--- a/apps/server/src/environment/Layers/ServerEnvironmentLabel.test.ts
+++ b/apps/server/src/environment/ServerEnvironmentLabel.test.ts
@@ -2,12 +2,12 @@ import { afterEach, describe, expect, it } from "@effect/vitest";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Layer from "effect/Layer";
+import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner";
import { HostProcessHostname, HostProcessPlatform } from "@t3tools/shared/hostProcess";
import { vi } from "vite-plus/test";
-import * as ProcessRunner from "../../processRunner.ts";
+import * as ProcessRunner from "../processRunner.ts";
import { resolveServerEnvironmentLabel } from "./ServerEnvironmentLabel.ts";
-import { ChildProcessSpawner } from "effect/unstable/process";
const runMock = vi.fn();
diff --git a/apps/server/src/environment/Layers/ServerEnvironmentLabel.ts b/apps/server/src/environment/ServerEnvironmentLabel.ts
similarity index 96%
rename from apps/server/src/environment/Layers/ServerEnvironmentLabel.ts
rename to apps/server/src/environment/ServerEnvironmentLabel.ts
index 73a3b9526c4..83c3b8bad8e 100644
--- a/apps/server/src/environment/Layers/ServerEnvironmentLabel.ts
+++ b/apps/server/src/environment/ServerEnvironmentLabel.ts
@@ -3,7 +3,7 @@ import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Option from "effect/Option";
-import { ProcessRunner } from "../../processRunner.ts";
+import * as ProcessRunner from "../processRunner.ts";
interface ResolveServerEnvironmentLabelInput {
readonly cwdBaseName: string;
@@ -50,7 +50,7 @@ const runFriendlyLabelCommand = Effect.fn("runFriendlyLabelCommand")(function* (
command: string,
args: readonly string[],
) {
- const processRunner = yield* ProcessRunner;
+ const processRunner = yield* ProcessRunner.ProcessRunner;
const result = yield* processRunner
.run({
command,
diff --git a/apps/server/src/environment/Services/ServerEnvironment.ts b/apps/server/src/environment/Services/ServerEnvironment.ts
deleted file mode 100644
index 1e6dea0d05f..00000000000
--- a/apps/server/src/environment/Services/ServerEnvironment.ts
+++ /dev/null
@@ -1,12 +0,0 @@
-import type { EnvironmentId, ExecutionEnvironmentDescriptor } from "@t3tools/contracts";
-import * as Context from "effect/Context";
-import type * as Effect from "effect/Effect";
-
-export interface ServerEnvironmentShape {
- readonly getEnvironmentId: Effect.Effect;
- readonly getDescriptor: Effect.Effect;
-}
-
-export class ServerEnvironment extends Context.Service()(
- "t3/environment/Services/ServerEnvironment",
-) {}
diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts
index 2b296e5f3fa..3ff9a42390e 100644
--- a/apps/server/src/git/GitManager.test.ts
+++ b/apps/server/src/git/GitManager.test.ts
@@ -20,7 +20,6 @@ import type {
} from "@t3tools/contracts";
import { GitCommandError, TextGenerationError } from "@t3tools/contracts";
-import * as GitManager from "./GitManager.ts";
import * as GitHubCli from "../sourceControl/GitHubCli.ts";
import * as TextGeneration from "../textGeneration/TextGeneration.ts";
import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
@@ -28,8 +27,9 @@ import * as VcsProcess from "../vcs/VcsProcess.ts";
import * as GitHubSourceControlProvider from "../sourceControl/GitHubSourceControlProvider.ts";
import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts";
import * as ServerConfig from "../config.ts";
+import * as ProjectSetupScriptRunner from "../project/ProjectSetupScriptRunner.ts";
import * as ServerSettings from "../serverSettings.ts";
-import * as ProjectSetupScriptRunner from "../project/Services/ProjectSetupScriptRunner.ts";
+import * as GitManager from "./GitManager.ts";
interface FakeGhScenario {
prListSequence?: string[];
@@ -3215,10 +3215,13 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
},
},
setupScriptRunner: {
- runForThread: () =>
+ runForThread: (input) =>
Effect.fail(
- new ProjectSetupScriptRunner.ProjectSetupScriptRunnerError({
- message: "terminal start failed",
+ new ProjectSetupScriptRunner.ProjectSetupScriptOperationError({
+ threadId: input.threadId,
+ worktreePath: input.worktreePath,
+ operation: "openTerminal",
+ cause: new Error("terminal start failed"),
}),
),
},
diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts
index c57c814f437..88eb0e21282 100644
--- a/apps/server/src/git/GitManager.ts
+++ b/apps/server/src/git/GitManager.ts
@@ -43,7 +43,7 @@ import {
import { GitManagerError } from "@t3tools/contracts";
import * as TextGeneration from "../textGeneration/TextGeneration.ts";
-import * as ProjectSetupScriptRunner from "../project/Services/ProjectSetupScriptRunner.ts";
+import * as ProjectSetupScriptRunner from "../project/ProjectSetupScriptRunner.ts";
import { extractBranchNameFromRemoteRef } from "./remoteRefs.ts";
import * as ServerSettings from "../serverSettings.ts";
import type { GitManagerServiceError } from "@t3tools/contracts";
diff --git a/apps/server/src/http.ts b/apps/server/src/http.ts
index 032fd501b01..0528d5e523d 100644
--- a/apps/server/src/http.ts
+++ b/apps/server/src/http.ts
@@ -39,7 +39,7 @@ import {
failEnvironmentAuthInvalid,
failEnvironmentInternal,
} from "./auth/http.ts";
-import * as ServerEnvironment from "./environment/Services/ServerEnvironment.ts";
+import * as ServerEnvironment from "./environment/ServerEnvironment.ts";
import { browserApiCorsAllowedHeaders, browserApiCorsAllowedMethods } from "./httpCors.ts";
const OTLP_TRACES_PROXY_PATH = "/api/observability/v1/traces";
diff --git a/apps/server/src/mcp/McpSessionRegistry.test.ts b/apps/server/src/mcp/McpSessionRegistry.test.ts
index 7616affaafd..a91d98febd8 100644
--- a/apps/server/src/mcp/McpSessionRegistry.test.ts
+++ b/apps/server/src/mcp/McpSessionRegistry.test.ts
@@ -4,7 +4,7 @@ import { EnvironmentId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"
import * as Effect from "effect/Effect";
import { HttpServer } from "effect/unstable/http";
-import { ServerEnvironment } from "../environment/Services/ServerEnvironment.ts";
+import * as ServerEnvironment from "../environment/ServerEnvironment.ts";
import * as McpSessionRegistry from "./McpSessionRegistry.ts";
const environmentId = EnvironmentId.make("environment-1");
@@ -14,7 +14,7 @@ const makeFakeHttpServer = (hostname: string, port = 43123) =>
serve: (() => Effect.void) as HttpServer.HttpServer["Service"]["serve"],
});
const fakeHttpServer = makeFakeHttpServer("127.0.0.1");
-const fakeEnvironment = ServerEnvironment.of({
+const fakeEnvironment = ServerEnvironment.ServerEnvironment.of({
getEnvironmentId: Effect.succeed(environmentId),
getDescriptor: Effect.die("unused"),
});
@@ -28,7 +28,7 @@ const makeRegistry = (now: () => number, httpServer = fakeHttpServer) =>
})
.pipe(
Effect.provideService(HttpServer.HttpServer, httpServer),
- Effect.provideService(ServerEnvironment, fakeEnvironment),
+ Effect.provideService(ServerEnvironment.ServerEnvironment, fakeEnvironment),
Effect.provide(NodeServices.layer),
);
diff --git a/apps/server/src/mcp/McpSessionRegistry.ts b/apps/server/src/mcp/McpSessionRegistry.ts
index c15480310d5..de9dc958415 100644
--- a/apps/server/src/mcp/McpSessionRegistry.ts
+++ b/apps/server/src/mcp/McpSessionRegistry.ts
@@ -7,7 +7,7 @@ import * as Layer from "effect/Layer";
import * as SynchronizedRef from "effect/SynchronizedRef";
import { HttpServer } from "effect/unstable/http";
-import { ServerEnvironment } from "../environment/Services/ServerEnvironment.ts";
+import * as ServerEnvironment from "../environment/ServerEnvironment.ts";
import * as McpInvocationContext from "./McpInvocationContext.ts";
import * as McpProviderSession from "./McpProviderSession.ts";
@@ -75,7 +75,7 @@ const makeWithOptions = Effect.fn("McpSessionRegistry.make")(function* (
options: McpSessionRegistryOptions = {},
) {
const crypto = yield* Crypto.Crypto;
- const environment = yield* ServerEnvironment;
+ const environment = yield* ServerEnvironment.ServerEnvironment;
const environmentId = yield* environment.getEnvironmentId;
const httpServer = yield* HttpServer.HttpServer;
const state = yield* SynchronizedRef.make({ records: new Map() });
@@ -194,7 +194,7 @@ const make = Effect.acquireRelease(
export const layer: Layer.Layer<
McpSessionRegistry,
never,
- Crypto.Crypto | ServerEnvironment | HttpServer.HttpServer
+ Crypto.Crypto | ServerEnvironment.ServerEnvironment | HttpServer.HttpServer
> = Layer.effect(McpSessionRegistry, make);
export const issueActiveMcpCredential = (
diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts
index 4bb5afbb476..07c543264f7 100644
--- a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts
+++ b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts
@@ -34,7 +34,7 @@ import * as CheckpointStore from "../../checkpointing/CheckpointStore.ts";
import * as VcsDriverRegistry from "../../vcs/VcsDriverRegistry.ts";
import * as VcsProcess from "../../vcs/VcsProcess.ts";
import { VcsStatusBroadcaster } from "../../vcs/VcsStatusBroadcaster.ts";
-import { RepositoryIdentityResolverLive } from "../../project/Layers/RepositoryIdentityResolver.ts";
+import * as RepositoryIdentityResolver from "../../project/RepositoryIdentityResolver.ts";
import { CheckpointReactorLive } from "./CheckpointReactor.ts";
import { OrchestrationEngineLive } from "./OrchestrationEngine.ts";
import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts";
@@ -56,7 +56,7 @@ import {
import { checkpointRefForThreadTurn } from "../../checkpointing/Utils.ts";
import { ServerConfig } from "../../config.ts";
import * as WorkspaceEntries from "../../workspace/WorkspaceEntries.ts";
-import { WorkspacePathsLive } from "../../workspace/Layers/WorkspacePaths.ts";
+import * as WorkspacePaths from "../../workspace/WorkspacePaths.ts";
const asProjectId = (value: string): ProjectId => ProjectId.make(value);
const asTurnId = (value: string): TurnId => TurnId.make(value);
@@ -294,11 +294,11 @@ describe("CheckpointReactor", () => {
Layer.provide(OrchestrationProjectionPipelineLive),
Layer.provide(OrchestrationEventStoreLive),
Layer.provide(OrchestrationCommandReceiptRepositoryLive),
- Layer.provide(RepositoryIdentityResolverLive),
+ Layer.provide(RepositoryIdentityResolver.layer),
Layer.provide(SqlitePersistenceMemory),
);
const projectionSnapshotLayer = OrchestrationProjectionSnapshotQueryLive.pipe(
- Layer.provide(RepositoryIdentityResolverLive),
+ Layer.provide(RepositoryIdentityResolver.layer),
Layer.provide(SqlitePersistenceMemory),
);
@@ -333,11 +333,11 @@ describe("CheckpointReactor", () => {
Layer.provideMerge(CheckpointStore.layer.pipe(Layer.provide(VcsDriverRegistry.layer))),
Layer.provideMerge(
WorkspaceEntries.layer.pipe(
- Layer.provide(WorkspacePathsLive),
+ Layer.provide(WorkspacePaths.layer),
Layer.provideMerge(VcsDriverRegistry.layer),
),
),
- Layer.provideMerge(WorkspacePathsLive),
+ Layer.provideMerge(WorkspacePaths.layer),
Layer.provideMerge(VcsProcess.layer),
Layer.provideMerge(ServerConfigLayer),
Layer.provideMerge(NodeServices.layer),
diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts
index 56876ec148e..b2ef0fed0f9 100644
--- a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts
+++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts
@@ -27,7 +27,7 @@ import {
OrchestrationEventStore,
type OrchestrationEventStoreShape,
} from "../../persistence/Services/OrchestrationEventStore.ts";
-import { RepositoryIdentityResolverLive } from "../../project/Layers/RepositoryIdentityResolver.ts";
+import * as RepositoryIdentityResolver from "../../project/RepositoryIdentityResolver.ts";
import { OrchestrationEngineLive } from "./OrchestrationEngine.ts";
import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts";
import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts";
@@ -57,7 +57,7 @@ async function createOrchestrationSystem() {
).pipe(
Layer.provide(OrchestrationEventStoreLive),
Layer.provide(OrchestrationCommandReceiptRepositoryLive),
- Layer.provide(RepositoryIdentityResolverLive),
+ Layer.provide(RepositoryIdentityResolver.layer),
Layer.provide(SqlitePersistenceMemory),
Layer.provideMerge(ServerConfigLayer),
Layer.provideMerge(NodeServices.layer),
@@ -680,7 +680,7 @@ describe("OrchestrationEngine", () => {
Layer.provide(OrchestrationProjectionPipelineLive),
Layer.provide(Layer.succeed(OrchestrationEventStore, flakyStore)),
Layer.provide(OrchestrationCommandReceiptRepositoryLive),
- Layer.provide(RepositoryIdentityResolverLive),
+ Layer.provide(RepositoryIdentityResolver.layer),
Layer.provide(SqlitePersistenceMemory),
Layer.provideMerge(ServerConfigLayer),
Layer.provideMerge(NodeServices.layer),
@@ -785,7 +785,7 @@ describe("OrchestrationEngine", () => {
Layer.provide(Layer.succeed(OrchestrationProjectionPipeline, flakyProjectionPipeline)),
Layer.provide(OrchestrationEventStoreLive),
Layer.provide(OrchestrationCommandReceiptRepositoryLive),
- Layer.provide(RepositoryIdentityResolverLive),
+ Layer.provide(RepositoryIdentityResolver.layer),
Layer.provide(SqlitePersistenceMemory),
Layer.provide(NodeServices.layer),
),
@@ -928,7 +928,7 @@ describe("OrchestrationEngine", () => {
Layer.provide(Layer.succeed(OrchestrationProjectionPipeline, flakyProjectionPipeline)),
Layer.provide(Layer.succeed(OrchestrationEventStore, nonTransactionalStore)),
Layer.provide(OrchestrationCommandReceiptRepositoryLive),
- Layer.provide(RepositoryIdentityResolverLive),
+ Layer.provide(RepositoryIdentityResolver.layer),
Layer.provide(SqlitePersistenceMemory),
Layer.provide(NodeServices.layer),
),
diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts
index 5a997de3669..0999000ed4f 100644
--- a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts
+++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts
@@ -24,7 +24,7 @@ import {
SqlitePersistenceMemory,
} from "../../persistence/Layers/Sqlite.ts";
import { OrchestrationEventStore } from "../../persistence/Services/OrchestrationEventStore.ts";
-import { RepositoryIdentityResolverLive } from "../../project/Layers/RepositoryIdentityResolver.ts";
+import * as RepositoryIdentityResolver from "../../project/RepositoryIdentityResolver.ts";
import { OrchestrationEngineLive } from "./OrchestrationEngine.ts";
import {
ORCHESTRATION_PROJECTOR_NAMES,
@@ -2535,7 +2535,7 @@ const engineLayer = it.layer(
Layer.provide(OrchestrationProjectionPipelineLive),
Layer.provide(OrchestrationEventStoreLive),
Layer.provide(OrchestrationCommandReceiptRepositoryLive),
- Layer.provide(RepositoryIdentityResolverLive),
+ Layer.provide(RepositoryIdentityResolver.layer),
Layer.provideMerge(SqlitePersistenceMemory),
Layer.provideMerge(
ServerConfig.layerTest(process.cwd(), {
diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts
index 7db2a23e5ec..9a136b06872 100644
--- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts
+++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts
@@ -14,8 +14,7 @@ import * as Layer from "effect/Layer";
import * as SqlClient from "effect/unstable/sql/SqlClient";
import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts";
-import { RepositoryIdentityResolver } from "../../project/Services/RepositoryIdentityResolver.ts";
-import { RepositoryIdentityResolverLive } from "../../project/Layers/RepositoryIdentityResolver.ts";
+import * as RepositoryIdentityResolver from "../../project/RepositoryIdentityResolver.ts";
import { ORCHESTRATION_PROJECTOR_NAMES } from "./ProjectionPipeline.ts";
import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts";
import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts";
@@ -28,7 +27,7 @@ const asCheckpointRef = (value: string): CheckpointRef => CheckpointRef.make(val
const projectionSnapshotLayer = it.layer(
OrchestrationProjectionSnapshotQueryLive.pipe(
- Layer.provideMerge(RepositoryIdentityResolverLive),
+ Layer.provideMerge(RepositoryIdentityResolver.layer),
Layer.provideMerge(SqlitePersistenceMemory),
Layer.provideMerge(NodeServices.layer),
),
@@ -1441,7 +1440,7 @@ it.effect(
const resolveCalls: string[] = [];
const layer = OrchestrationProjectionSnapshotQueryLive.pipe(
Layer.provideMerge(
- Layer.succeed(RepositoryIdentityResolver, {
+ Layer.succeed(RepositoryIdentityResolver.RepositoryIdentityResolver, {
resolve: (cwd: string) =>
Effect.sync(() => {
resolveCalls.push(cwd);
diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts
index e629d1604b3..e36db35b107 100644
--- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts
+++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts
@@ -48,7 +48,7 @@ import { ProjectionThreadMessage } from "../../persistence/Services/ProjectionTh
import { ProjectionThreadProposedPlan } from "../../persistence/Services/ProjectionThreadProposedPlans.ts";
import { ProjectionThreadSession } from "../../persistence/Services/ProjectionThreadSessions.ts";
import { ProjectionThread } from "../../persistence/Services/ProjectionThreads.ts";
-import { RepositoryIdentityResolver } from "../../project/Services/RepositoryIdentityResolver.ts";
+import * as RepositoryIdentityResolver from "../../project/RepositoryIdentityResolver.ts";
import { ORCHESTRATION_PROJECTOR_NAMES } from "./ProjectionPipeline.ts";
import {
ProjectionSnapshotQuery,
@@ -262,7 +262,7 @@ function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: st
const makeProjectionSnapshotQuery = Effect.gen(function* () {
const sql = yield* SqlClient.SqlClient;
- const repositoryIdentityResolver = yield* RepositoryIdentityResolver;
+ const repositoryIdentityResolver = yield* RepositoryIdentityResolver.RepositoryIdentityResolver;
const repositoryIdentityResolutionConcurrency = 4;
const resolveRepositoryIdentitiesForProjects = Effect.fn(
"ProjectionSnapshotQuery.resolveRepositoryIdentitiesForProjects",
diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts
index 0e399f03ab8..8041bc66dd3 100644
--- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts
+++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts
@@ -43,7 +43,7 @@ import {
} from "../../provider/Services/ProviderService.ts";
import { makeProviderRegistryLayer } from "../../provider/testUtils/providerRegistryMock.ts";
import { TextGeneration, type TextGenerationShape } from "../../textGeneration/TextGeneration.ts";
-import { RepositoryIdentityResolverLive } from "../../project/Layers/RepositoryIdentityResolver.ts";
+import * as RepositoryIdentityResolver from "../../project/RepositoryIdentityResolver.ts";
import { OrchestrationEngineLive } from "./OrchestrationEngine.ts";
import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts";
import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts";
@@ -335,11 +335,11 @@ describe("ProviderCommandReactor", () => {
Layer.provide(OrchestrationProjectionPipelineLive),
Layer.provide(OrchestrationEventStoreLive),
Layer.provide(OrchestrationCommandReceiptRepositoryLive),
- Layer.provide(RepositoryIdentityResolverLive),
+ Layer.provide(RepositoryIdentityResolver.layer),
Layer.provide(SqlitePersistenceMemory),
);
const projectionSnapshotLayer = OrchestrationProjectionSnapshotQueryLive.pipe(
- Layer.provide(RepositoryIdentityResolverLive),
+ Layer.provide(RepositoryIdentityResolver.layer),
Layer.provide(SqlitePersistenceMemory),
);
const layer = ProviderCommandReactorLive.pipe(
diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts
index 64955590235..2aaa7ea9a33 100644
--- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts
+++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts
@@ -39,7 +39,7 @@ import {
ProviderService,
type ProviderServiceShape,
} from "../../provider/Services/ProviderService.ts";
-import { RepositoryIdentityResolverLive } from "../../project/Layers/RepositoryIdentityResolver.ts";
+import * as RepositoryIdentityResolver from "../../project/RepositoryIdentityResolver.ts";
import { OrchestrationEngineLive } from "./OrchestrationEngine.ts";
import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts";
import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts";
@@ -226,11 +226,11 @@ describe("ProviderRuntimeIngestion", () => {
Layer.provide(OrchestrationProjectionPipelineLive),
Layer.provide(OrchestrationEventStoreLive),
Layer.provide(OrchestrationCommandReceiptRepositoryLive),
- Layer.provide(RepositoryIdentityResolverLive),
+ Layer.provide(RepositoryIdentityResolver.layer),
Layer.provide(SqlitePersistenceMemory),
);
const projectionSnapshotLayer = OrchestrationProjectionSnapshotQueryLive.pipe(
- Layer.provide(RepositoryIdentityResolverLive),
+ Layer.provide(RepositoryIdentityResolver.layer),
Layer.provide(SqlitePersistenceMemory),
);
const layer = ProviderRuntimeIngestionLive.pipe(
diff --git a/apps/server/src/orchestration/Normalizer.ts b/apps/server/src/orchestration/Normalizer.ts
index 95d29e3d6d2..bed166eba45 100644
--- a/apps/server/src/orchestration/Normalizer.ts
+++ b/apps/server/src/orchestration/Normalizer.ts
@@ -11,14 +11,14 @@ import {
import { createAttachmentId, resolveAttachmentPath } from "../attachmentStore.ts";
import { ServerConfig } from "../config.ts";
import { parseBase64DataUrl } from "../imageMime.ts";
-import { WorkspacePaths } from "../workspace/Services/WorkspacePaths.ts";
+import * as WorkspacePaths from "../workspace/WorkspacePaths.ts";
export const normalizeDispatchCommand = (command: ClientOrchestrationCommand) =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const serverConfig = yield* ServerConfig;
- const workspacePaths = yield* WorkspacePaths;
+ const workspacePaths = yield* WorkspacePaths.WorkspacePaths;
const normalizeProjectWorkspaceRoot = (workspaceRoot: string) =>
workspacePaths.normalizeWorkspaceRoot(workspaceRoot).pipe(
diff --git a/apps/server/src/project/Layers/ProjectSetupScriptRunner.test.ts b/apps/server/src/project/Layers/ProjectSetupScriptRunner.test.ts
deleted file mode 100644
index 91d39a3c1ea..00000000000
--- a/apps/server/src/project/Layers/ProjectSetupScriptRunner.test.ts
+++ /dev/null
@@ -1,165 +0,0 @@
-import { ProjectId, type OrchestrationProject } from "@t3tools/contracts";
-import * as Effect from "effect/Effect";
-import * as Layer from "effect/Layer";
-import * as Option from "effect/Option";
-import { describe, expect, it, vi } from "vite-plus/test";
-
-import { ProjectionSnapshotQuery } from "../../orchestration/Services/ProjectionSnapshotQuery.ts";
-import * as TerminalManager from "../../terminal/Manager.ts";
-import { ProjectSetupScriptRunner } from "../Services/ProjectSetupScriptRunner.ts";
-import { ProjectSetupScriptRunnerLive } from "./ProjectSetupScriptRunner.ts";
-
-const makeProject = (scripts: OrchestrationProject["scripts"]): OrchestrationProject => ({
- id: ProjectId.make("project-1"),
- title: "Project",
- workspaceRoot: "/repo/project",
- defaultModelSelection: null,
- scripts,
- createdAt: "2026-01-01T00:00:00.000Z",
- updatedAt: "2026-01-01T00:00:00.000Z",
- deletedAt: null,
-});
-
-const makeProjectionSnapshotQueryLayer = (project: OrchestrationProject) =>
- Layer.succeed(ProjectionSnapshotQuery, {
- getCommandReadModel: () => Effect.die("unused"),
- getSnapshot: () => Effect.die("unused"),
- getShellSnapshot: () => Effect.die("unused"),
- getArchivedShellSnapshot: () => Effect.die("unused"),
- getSnapshotSequence: () => Effect.succeed({ snapshotSequence: 1 }),
- getCounts: () => Effect.die("unused"),
- getActiveProjectByWorkspaceRoot: (workspaceRoot) =>
- Effect.succeed(
- workspaceRoot === project.workspaceRoot ? Option.some(project) : Option.none(),
- ),
- getProjectShellById: (projectId) =>
- Effect.succeed(projectId === project.id ? Option.some(project) : Option.none()),
- getFirstActiveThreadIdByProjectId: () => Effect.die("unused"),
- getThreadCheckpointContext: () => Effect.die("unused"),
- getFullThreadDiffContext: () => Effect.die("unused"),
- getThreadShellById: () => Effect.die("unused"),
- getThreadDetailById: () => Effect.die("unused"),
- });
-
-describe("ProjectSetupScriptRunner", () => {
- it("returns no-script when no setup script exists", async () => {
- const open = vi.fn();
- const write = vi.fn();
- const project = makeProject([]);
- const runner = await Effect.runPromise(
- Effect.service(ProjectSetupScriptRunner).pipe(
- Effect.provide(
- ProjectSetupScriptRunnerLive.pipe(
- Layer.provideMerge(makeProjectionSnapshotQueryLayer(project)),
- Layer.provideMerge(
- Layer.succeed(TerminalManager.TerminalManager, {
- open,
- attachStream: () => Effect.die(new Error("unused")),
- write,
- resize: () => Effect.void,
- clear: () => Effect.void,
- restart: () => Effect.die(new Error("unused")),
- close: () => Effect.void,
- subscribe: () => Effect.succeed(() => undefined),
- subscribeMetadata: () => Effect.succeed(() => undefined),
- }),
- ),
- ),
- ),
- ),
- );
-
- const result = await Effect.runPromise(
- runner.runForThread({
- threadId: "thread-1",
- projectId: "project-1",
- worktreePath: "/repo/worktrees/a",
- }),
- );
-
- expect(result).toEqual({ status: "no-script" });
- expect(open).not.toHaveBeenCalled();
- expect(write).not.toHaveBeenCalled();
- });
-
- it("opens the deterministic setup terminal with worktree env and writes the command", async () => {
- const open = vi.fn(() =>
- Effect.succeed({
- threadId: "thread-1",
- terminalId: "setup-setup",
- cwd: "/repo/worktrees/a",
- worktreePath: "/repo/worktrees/a",
- status: "running" as const,
- pid: 123,
- history: "",
- exitCode: null,
- exitSignal: null,
- label: "setup-setup",
- updatedAt: "2026-01-01T00:00:00.000Z",
- }),
- );
- const write = vi.fn(() => Effect.void);
- const project = makeProject([
- {
- id: "setup",
- name: "Setup",
- command: "bun install",
- icon: "configure",
- runOnWorktreeCreate: true,
- },
- ]);
- const runner = await Effect.runPromise(
- Effect.service(ProjectSetupScriptRunner).pipe(
- Effect.provide(
- ProjectSetupScriptRunnerLive.pipe(
- Layer.provideMerge(makeProjectionSnapshotQueryLayer(project)),
- Layer.provideMerge(
- Layer.succeed(TerminalManager.TerminalManager, {
- open,
- attachStream: () => Effect.die(new Error("unused")),
- write,
- resize: () => Effect.void,
- clear: () => Effect.void,
- restart: () => Effect.die(new Error("unused")),
- close: () => Effect.void,
- subscribe: () => Effect.succeed(() => undefined),
- subscribeMetadata: () => Effect.succeed(() => undefined),
- }),
- ),
- ),
- ),
- ),
- );
-
- const result = await Effect.runPromise(
- runner.runForThread({
- threadId: "thread-1",
- projectCwd: "/repo/project",
- worktreePath: "/repo/worktrees/a",
- }),
- );
-
- expect(result).toEqual({
- status: "started",
- scriptId: "setup",
- scriptName: "Setup",
- terminalId: "setup-setup",
- cwd: "/repo/worktrees/a",
- });
- expect(open).toHaveBeenCalledWith({
- threadId: "thread-1",
- terminalId: "setup-setup",
- cwd: "/repo/worktrees/a",
- worktreePath: "/repo/worktrees/a",
- env: {
- T3CODE_PROJECT_ROOT: "/repo/project",
- T3CODE_WORKTREE_PATH: "/repo/worktrees/a",
- },
- });
- expect(write).toHaveBeenCalledWith({
- threadId: "thread-1",
- terminalId: "setup-setup",
- data: "bun install\r",
- });
- });
-});
diff --git a/apps/server/src/project/Layers/ProjectSetupScriptRunner.ts b/apps/server/src/project/Layers/ProjectSetupScriptRunner.ts
deleted file mode 100644
index 3c8772641be..00000000000
--- a/apps/server/src/project/Layers/ProjectSetupScriptRunner.ts
+++ /dev/null
@@ -1,103 +0,0 @@
-import { ProjectId } from "@t3tools/contracts";
-import { projectScriptRuntimeEnv, setupProjectScript } from "@t3tools/shared/projectScripts";
-import * as Effect from "effect/Effect";
-import * as Layer from "effect/Layer";
-import * as Option from "effect/Option";
-
-import { ProjectionSnapshotQuery } from "../../orchestration/Services/ProjectionSnapshotQuery.ts";
-import * as TerminalManager from "../../terminal/Manager.ts";
-import {
- type ProjectSetupScriptRunnerShape,
- ProjectSetupScriptRunner,
- ProjectSetupScriptRunnerError,
-} from "../Services/ProjectSetupScriptRunner.ts";
-
-const makeProjectSetupScriptRunner = Effect.gen(function* () {
- const projectionSnapshotQuery = yield* ProjectionSnapshotQuery;
- const terminalManager = yield* TerminalManager.TerminalManager;
-
- const runForThread: ProjectSetupScriptRunnerShape["runForThread"] = (input) =>
- Effect.gen(function* () {
- const project =
- (input.projectId
- ? yield* projectionSnapshotQuery
- .getProjectShellById(ProjectId.make(input.projectId))
- .pipe(Effect.map(Option.getOrUndefined))
- : null) ??
- (input.projectCwd
- ? yield* projectionSnapshotQuery
- .getActiveProjectByWorkspaceRoot(input.projectCwd)
- .pipe(Effect.map(Option.getOrUndefined))
- : null) ??
- null;
-
- if (!project) {
- return yield* new ProjectSetupScriptRunnerError({
- message: "Project was not found for setup script execution.",
- });
- }
-
- const script = setupProjectScript(project.scripts);
- if (!script) {
- return {
- status: "no-script",
- } as const;
- }
-
- const terminalId = input.preferredTerminalId ?? `setup-${script.id}`;
- const cwd = input.worktreePath;
- const env = projectScriptRuntimeEnv({
- project: { cwd: project.workspaceRoot },
- worktreePath: input.worktreePath,
- });
-
- yield* terminalManager.open({
- threadId: input.threadId,
- terminalId,
- cwd,
- worktreePath: input.worktreePath,
- env,
- });
- yield* terminalManager.write({
- threadId: input.threadId,
- terminalId,
- data: `${script.command}\r`,
- });
-
- return {
- status: "started",
- scriptId: script.id,
- scriptName: script.name,
- terminalId,
- cwd,
- } as const;
- }).pipe(
- Effect.mapError((cause) => {
- if (
- typeof cause === "object" &&
- cause !== null &&
- "_tag" in cause &&
- cause._tag === "ProjectSetupScriptRunnerError"
- ) {
- return cause as ProjectSetupScriptRunnerError;
- }
- const message =
- typeof cause === "object" &&
- cause !== null &&
- "message" in cause &&
- typeof cause.message === "string"
- ? cause.message
- : String(cause);
- return new ProjectSetupScriptRunnerError({ message });
- }),
- );
-
- return {
- runForThread,
- } satisfies ProjectSetupScriptRunnerShape;
-});
-
-export const ProjectSetupScriptRunnerLive = Layer.effect(
- ProjectSetupScriptRunner,
- makeProjectSetupScriptRunner,
-);
diff --git a/apps/server/src/project/Layers/ProjectFaviconResolver.test.ts b/apps/server/src/project/ProjectFaviconResolver.test.ts
similarity index 82%
rename from apps/server/src/project/Layers/ProjectFaviconResolver.test.ts
rename to apps/server/src/project/ProjectFaviconResolver.test.ts
index 5c0e5d95742..37bda11e6aa 100644
--- a/apps/server/src/project/Layers/ProjectFaviconResolver.test.ts
+++ b/apps/server/src/project/ProjectFaviconResolver.test.ts
@@ -5,12 +5,11 @@ import * as FileSystem from "effect/FileSystem";
import * as Layer from "effect/Layer";
import * as Path from "effect/Path";
-import { ProjectFaviconResolver } from "../Services/ProjectFaviconResolver.ts";
-import { ProjectFaviconResolverLive } from "./ProjectFaviconResolver.ts";
-import { WorkspacePathsLive } from "../../workspace/Layers/WorkspacePaths.ts";
+import * as WorkspacePaths from "../workspace/WorkspacePaths.ts";
+import * as ProjectFaviconResolver from "./ProjectFaviconResolver.ts";
const TestLayer = Layer.empty.pipe(
- Layer.provideMerge(ProjectFaviconResolverLive.pipe(Layer.provide(WorkspacePathsLive))),
+ Layer.provideMerge(ProjectFaviconResolver.layer.pipe(Layer.provide(WorkspacePaths.layer))),
Layer.provideMerge(NodeServices.layer),
);
@@ -39,7 +38,7 @@ it.layer(TestLayer)("ProjectFaviconResolverLive", (it) => {
describe("resolvePath", () => {
it.effect("prefers well-known favicon files", () =>
Effect.gen(function* () {
- const resolver = yield* ProjectFaviconResolver;
+ const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver;
const cwd = yield* makeTempDir;
yield* writeTextFile(cwd, "favicon.svg", "");
@@ -52,7 +51,7 @@ it.layer(TestLayer)("ProjectFaviconResolverLive", (it) => {
it.effect("resolves icon hrefs from project source files", () =>
Effect.gen(function* () {
- const resolver = yield* ProjectFaviconResolver;
+ const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver;
const cwd = yield* makeTempDir;
yield* writeTextFile(cwd, "index.html", '');
yield* writeTextFile(cwd, "public/brand/logo.svg", "");
@@ -66,7 +65,7 @@ it.layer(TestLayer)("ProjectFaviconResolverLive", (it) => {
it.effect("returns null when no icon is present", () =>
Effect.gen(function* () {
- const resolver = yield* ProjectFaviconResolver;
+ const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver;
const cwd = yield* makeTempDir;
const resolved = yield* resolver.resolvePath(cwd);
diff --git a/apps/server/src/project/Layers/ProjectFaviconResolver.ts b/apps/server/src/project/ProjectFaviconResolver.ts
similarity index 75%
rename from apps/server/src/project/Layers/ProjectFaviconResolver.ts
rename to apps/server/src/project/ProjectFaviconResolver.ts
index a994d1a7e8c..4c685a20f88 100644
--- a/apps/server/src/project/Layers/ProjectFaviconResolver.ts
+++ b/apps/server/src/project/ProjectFaviconResolver.ts
@@ -1,13 +1,18 @@
+/**
+ * ProjectFaviconResolver - Effect service contract for project icon discovery.
+ *
+ * Resolves a representative favicon or app icon file for a workspace by
+ * checking common file locations and project source metadata.
+ *
+ * @module ProjectFaviconResolver
+ */
+import * as Context from "effect/Context";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Layer from "effect/Layer";
import * as Path from "effect/Path";
-import {
- ProjectFaviconResolver,
- type ProjectFaviconResolverShape,
-} from "../Services/ProjectFaviconResolver.ts";
-import { WorkspacePaths } from "../../workspace/Services/WorkspacePaths.ts";
+import * as WorkspacePaths from "../workspace/WorkspacePaths.ts";
// Well-known favicon paths checked in order.
const FAVICON_CANDIDATES = [
@@ -51,6 +56,19 @@ const LINK_ICON_HTML_RE =
const LINK_ICON_OBJ_RE =
/(?=[^}]*\brel\s*:\s*["'](?:icon|shortcut icon)["'])(?=[^}]*\bhref\s*:\s*["']([^"'?]+))[^}]*/i;
+/** Service tag for project favicon resolution. */
+export class ProjectFaviconResolver extends Context.Service<
+ ProjectFaviconResolver,
+ {
+ /**
+ * Resolve a favicon or icon file path for the provided workspace root.
+ *
+ * Returns `null` when no candidate icon file can be found.
+ */
+ readonly resolvePath: (cwd: string) => Effect.Effect;
+ }
+>()("t3/project/ProjectFaviconResolver") {}
+
function extractIconHref(source: string): string | null {
const htmlMatch = source.match(LINK_ICON_HTML_RE);
if (htmlMatch?.[1]) return htmlMatch[1];
@@ -59,12 +77,12 @@ function extractIconHref(source: string): string | null {
return null;
}
-export const makeProjectFaviconResolver = Effect.gen(function* () {
+export const make = Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
- const workspacePaths = yield* WorkspacePaths;
+ const workspacePaths = yield* WorkspacePaths.WorkspacePaths;
- const resolveIconHref = (href: string): string[] => {
+ const resolveIconHref = (href: string): ReadonlyArray => {
const clean = href.replace(/^\//, "");
return [path.join("public", clean), clean];
};
@@ -93,9 +111,9 @@ export const makeProjectFaviconResolver = Effect.gen(function* () {
return null;
});
- const resolvePath: ProjectFaviconResolverShape["resolvePath"] = Effect.fn(
+ const resolvePath: ProjectFaviconResolver["Service"]["resolvePath"] = Effect.fn(
"ProjectFaviconResolver.resolvePath",
- )(function* (cwd: string): Effect.fn.Return {
+ )(function* (cwd) {
const projectCwd = yield* workspacePaths
.normalizeWorkspaceRoot(cwd)
.pipe(Effect.orElseSucceed(() => null));
@@ -138,12 +156,7 @@ export const makeProjectFaviconResolver = Effect.gen(function* () {
return null;
});
- return {
- resolvePath,
- } satisfies ProjectFaviconResolverShape;
+ return ProjectFaviconResolver.of({ resolvePath });
});
-export const ProjectFaviconResolverLive = Layer.effect(
- ProjectFaviconResolver,
- makeProjectFaviconResolver,
-);
+export const layer = Layer.effect(ProjectFaviconResolver, make);
diff --git a/apps/server/src/project/ProjectSetupScriptRunner.test.ts b/apps/server/src/project/ProjectSetupScriptRunner.test.ts
new file mode 100644
index 00000000000..d7a1bd15c58
--- /dev/null
+++ b/apps/server/src/project/ProjectSetupScriptRunner.test.ts
@@ -0,0 +1,148 @@
+import { describe, expect, it, vi } from "@effect/vitest";
+import { type OrchestrationProject, ProjectId } from "@t3tools/contracts";
+import * as Effect from "effect/Effect";
+import * as Layer from "effect/Layer";
+import * as Option from "effect/Option";
+
+import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts";
+import * as TerminalManager from "../terminal/Manager.ts";
+import * as ProjectSetupScriptRunner from "./ProjectSetupScriptRunner.ts";
+
+const makeProject = (scripts: OrchestrationProject["scripts"]): OrchestrationProject => ({
+ id: ProjectId.make("project-1"),
+ title: "Project",
+ workspaceRoot: "/repo/project",
+ defaultModelSelection: null,
+ scripts,
+ createdAt: "2026-01-01T00:00:00.000Z",
+ updatedAt: "2026-01-01T00:00:00.000Z",
+ deletedAt: null,
+});
+
+const makeProjectionSnapshotQueryLayer = (project: OrchestrationProject) =>
+ Layer.succeed(ProjectionSnapshotQuery.ProjectionSnapshotQuery, {
+ getCommandReadModel: () => Effect.die("unused"),
+ getSnapshot: () => Effect.die("unused"),
+ getShellSnapshot: () => Effect.die("unused"),
+ getArchivedShellSnapshot: () => Effect.die("unused"),
+ getSnapshotSequence: () => Effect.succeed({ snapshotSequence: 1 }),
+ getCounts: () => Effect.die("unused"),
+ getActiveProjectByWorkspaceRoot: (workspaceRoot) =>
+ Effect.succeed(
+ workspaceRoot === project.workspaceRoot ? Option.some(project) : Option.none(),
+ ),
+ getProjectShellById: (projectId) =>
+ Effect.succeed(projectId === project.id ? Option.some(project) : Option.none()),
+ getFirstActiveThreadIdByProjectId: () => Effect.die("unused"),
+ getThreadCheckpointContext: () => Effect.die("unused"),
+ getFullThreadDiffContext: () => Effect.die("unused"),
+ getThreadShellById: () => Effect.die("unused"),
+ getThreadDetailById: () => Effect.die("unused"),
+ });
+
+const makeTerminalManagerLayer = (
+ overrides: Pick,
+) =>
+ Layer.succeed(TerminalManager.TerminalManager, {
+ ...overrides,
+ attachStream: () => Effect.die(new Error("unused")),
+ resize: () => Effect.void,
+ clear: () => Effect.void,
+ restart: () => Effect.die(new Error("unused")),
+ close: () => Effect.void,
+ subscribe: () => Effect.succeed(() => undefined),
+ subscribeMetadata: () => Effect.succeed(() => undefined),
+ });
+
+const testLayer = (
+ project: OrchestrationProject,
+ terminal: Pick,
+) =>
+ ProjectSetupScriptRunner.layer.pipe(
+ Layer.provideMerge(makeProjectionSnapshotQueryLayer(project)),
+ Layer.provideMerge(makeTerminalManagerLayer(terminal)),
+ );
+
+describe("ProjectSetupScriptRunner", () => {
+ it.effect("returns no-script when no setup script exists", () => {
+ const open = vi.fn(() => Effect.die("unexpected open"));
+ const write = vi.fn(() => Effect.die("unexpected write"));
+ const project = makeProject([]);
+
+ return Effect.gen(function* () {
+ const runner = yield* ProjectSetupScriptRunner.ProjectSetupScriptRunner;
+ const result = yield* runner.runForThread({
+ threadId: "thread-1",
+ projectId: "project-1",
+ worktreePath: "/repo/worktrees/a",
+ });
+
+ expect(result).toEqual({ status: "no-script" });
+ expect(open).not.toHaveBeenCalled();
+ expect(write).not.toHaveBeenCalled();
+ }).pipe(Effect.provide(testLayer(project, { open, write })));
+ });
+
+ it.effect(
+ "opens the deterministic setup terminal with worktree env and writes the command",
+ () => {
+ const open = vi.fn(() =>
+ Effect.succeed({
+ threadId: "thread-1",
+ terminalId: "setup-setup",
+ cwd: "/repo/worktrees/a",
+ worktreePath: "/repo/worktrees/a",
+ status: "running" as const,
+ pid: 123,
+ history: "",
+ exitCode: null,
+ exitSignal: null,
+ label: "setup-setup",
+ updatedAt: "2026-01-01T00:00:00.000Z",
+ }),
+ );
+ const write = vi.fn(() => Effect.void);
+ const project = makeProject([
+ {
+ id: "setup",
+ name: "Setup",
+ command: "bun install",
+ icon: "configure",
+ runOnWorktreeCreate: true,
+ },
+ ]);
+
+ return Effect.gen(function* () {
+ const runner = yield* ProjectSetupScriptRunner.ProjectSetupScriptRunner;
+ const result = yield* runner.runForThread({
+ threadId: "thread-1",
+ projectCwd: "/repo/project",
+ worktreePath: "/repo/worktrees/a",
+ });
+
+ expect(result).toEqual({
+ status: "started",
+ scriptId: "setup",
+ scriptName: "Setup",
+ terminalId: "setup-setup",
+ cwd: "/repo/worktrees/a",
+ });
+ expect(open).toHaveBeenCalledWith({
+ threadId: "thread-1",
+ terminalId: "setup-setup",
+ cwd: "/repo/worktrees/a",
+ worktreePath: "/repo/worktrees/a",
+ env: {
+ T3CODE_PROJECT_ROOT: "/repo/project",
+ T3CODE_WORKTREE_PATH: "/repo/worktrees/a",
+ },
+ });
+ expect(write).toHaveBeenCalledWith({
+ threadId: "thread-1",
+ terminalId: "setup-setup",
+ data: "bun install\r",
+ });
+ }).pipe(Effect.provide(testLayer(project, { open, write })));
+ },
+ );
+});
diff --git a/apps/server/src/project/ProjectSetupScriptRunner.ts b/apps/server/src/project/ProjectSetupScriptRunner.ts
new file mode 100644
index 00000000000..57540088128
--- /dev/null
+++ b/apps/server/src/project/ProjectSetupScriptRunner.ts
@@ -0,0 +1,179 @@
+import { ProjectId } from "@t3tools/contracts";
+import { projectScriptRuntimeEnv, setupProjectScript } from "@t3tools/shared/projectScripts";
+import * as Context from "effect/Context";
+import * as Effect from "effect/Effect";
+import * as Layer from "effect/Layer";
+import * as Option from "effect/Option";
+import * as Schema from "effect/Schema";
+
+import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts";
+import * as TerminalManager from "../terminal/Manager.ts";
+
+export interface ProjectSetupScriptRunnerResultNoScript {
+ readonly status: "no-script";
+}
+
+export interface ProjectSetupScriptRunnerResultStarted {
+ readonly status: "started";
+ readonly scriptId: string;
+ readonly scriptName: string;
+ readonly terminalId: string;
+ readonly cwd: string;
+}
+
+export type ProjectSetupScriptRunnerResult =
+ | ProjectSetupScriptRunnerResultNoScript
+ | ProjectSetupScriptRunnerResultStarted;
+
+export interface ProjectSetupScriptRunnerInput {
+ readonly threadId: string;
+ readonly projectId?: string;
+ readonly projectCwd?: string;
+ readonly worktreePath: string;
+ readonly preferredTerminalId?: string;
+}
+
+export class ProjectSetupScriptOperationError extends Schema.TaggedErrorClass()(
+ "ProjectSetupScriptOperationError",
+ {
+ threadId: Schema.String,
+ projectId: Schema.optional(Schema.String),
+ projectCwd: Schema.optional(Schema.String),
+ worktreePath: Schema.String,
+ operation: Schema.Literals(["resolveProject", "openTerminal", "writeCommand"]),
+ cause: Schema.Defect(),
+ },
+) {
+ override get message(): string {
+ return `Project setup script operation '${this.operation}' failed for thread '${this.threadId}' in '${this.worktreePath}'.`;
+ }
+}
+
+export class ProjectSetupScriptProjectNotFoundError extends Schema.TaggedErrorClass()(
+ "ProjectSetupScriptProjectNotFoundError",
+ {
+ threadId: Schema.String,
+ projectId: Schema.optional(Schema.String),
+ projectCwd: Schema.optional(Schema.String),
+ worktreePath: Schema.String,
+ },
+) {
+ override get message(): string {
+ return `Project setup script project was not found for thread '${this.threadId}'.`;
+ }
+}
+
+export const ProjectSetupScriptRunnerError = Schema.Union([
+ ProjectSetupScriptOperationError,
+ ProjectSetupScriptProjectNotFoundError,
+]);
+export type ProjectSetupScriptRunnerError = typeof ProjectSetupScriptRunnerError.Type;
+
+export class ProjectSetupScriptRunner extends Context.Service<
+ ProjectSetupScriptRunner,
+ {
+ readonly runForThread: (
+ input: ProjectSetupScriptRunnerInput,
+ ) => Effect.Effect;
+ }
+>()("t3/project/ProjectSetupScriptRunner") {}
+
+const isProjectSetupScriptRunnerError = Schema.is(ProjectSetupScriptRunnerError);
+
+function operationError(
+ input: ProjectSetupScriptRunnerInput,
+ operation: ProjectSetupScriptOperationError["operation"],
+ cause: unknown,
+): ProjectSetupScriptOperationError {
+ return new ProjectSetupScriptOperationError({
+ threadId: input.threadId,
+ worktreePath: input.worktreePath,
+ operation,
+ cause,
+ ...(input.projectId === undefined ? {} : { projectId: input.projectId }),
+ ...(input.projectCwd === undefined ? {} : { projectCwd: input.projectCwd }),
+ });
+}
+
+function mapRunnerError(
+ input: ProjectSetupScriptRunnerInput,
+ operation: ProjectSetupScriptOperationError["operation"],
+) {
+ return Effect.mapError((cause: unknown) =>
+ isProjectSetupScriptRunnerError(cause) ? cause : operationError(input, operation, cause),
+ );
+}
+
+export const make = Effect.gen(function* () {
+ const projectionSnapshotQuery = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery;
+ const terminalManager = yield* TerminalManager.TerminalManager;
+
+ const runForThread: ProjectSetupScriptRunner["Service"]["runForThread"] = Effect.fn(
+ "ProjectSetupScriptRunner.runForThread",
+ )(function* (input) {
+ const projectById = input.projectId
+ ? yield* projectionSnapshotQuery
+ .getProjectShellById(ProjectId.make(input.projectId))
+ .pipe(Effect.map(Option.getOrUndefined), mapRunnerError(input, "resolveProject"))
+ : null;
+ const project =
+ projectById ??
+ (input.projectCwd
+ ? yield* projectionSnapshotQuery
+ .getActiveProjectByWorkspaceRoot(input.projectCwd)
+ .pipe(Effect.map(Option.getOrUndefined), mapRunnerError(input, "resolveProject"))
+ : null);
+
+ if (!project) {
+ return yield* new ProjectSetupScriptProjectNotFoundError({
+ threadId: input.threadId,
+ worktreePath: input.worktreePath,
+ ...(input.projectId === undefined ? {} : { projectId: input.projectId }),
+ ...(input.projectCwd === undefined ? {} : { projectCwd: input.projectCwd }),
+ });
+ }
+
+ const script = setupProjectScript(project.scripts);
+ if (!script) {
+ return {
+ status: "no-script",
+ } as const;
+ }
+
+ const terminalId = input.preferredTerminalId ?? `setup-${script.id}`;
+ const cwd = input.worktreePath;
+ const env = projectScriptRuntimeEnv({
+ project: { cwd: project.workspaceRoot },
+ worktreePath: input.worktreePath,
+ });
+
+ yield* terminalManager
+ .open({
+ threadId: input.threadId,
+ terminalId,
+ cwd,
+ worktreePath: input.worktreePath,
+ env,
+ })
+ .pipe(mapRunnerError(input, "openTerminal"));
+ yield* terminalManager
+ .write({
+ threadId: input.threadId,
+ terminalId,
+ data: `${script.command}\r`,
+ })
+ .pipe(mapRunnerError(input, "writeCommand"));
+
+ return {
+ status: "started",
+ scriptId: script.id,
+ scriptName: script.name,
+ terminalId,
+ cwd,
+ } as const;
+ });
+
+ return ProjectSetupScriptRunner.of({ runForThread });
+});
+
+export const layer = Layer.effect(ProjectSetupScriptRunner, make);
diff --git a/apps/server/src/project/Layers/RepositoryIdentityResolver.test.ts b/apps/server/src/project/RepositoryIdentityResolver.test.ts
similarity index 88%
rename from apps/server/src/project/Layers/RepositoryIdentityResolver.test.ts
rename to apps/server/src/project/RepositoryIdentityResolver.test.ts
index 1c985cd8592..a997459e63d 100644
--- a/apps/server/src/project/Layers/RepositoryIdentityResolver.test.ts
+++ b/apps/server/src/project/RepositoryIdentityResolver.test.ts
@@ -7,12 +7,8 @@ import * as Layer from "effect/Layer";
import * as Path from "effect/Path";
import { TestClock } from "effect/testing";
-import * as ProcessRunner from "../../processRunner.ts";
-import { RepositoryIdentityResolver } from "../Services/RepositoryIdentityResolver.ts";
-import {
- makeRepositoryIdentityResolver,
- RepositoryIdentityResolverLive,
-} from "./RepositoryIdentityResolver.ts";
+import * as ProcessRunner from "../processRunner.ts";
+import * as RepositoryIdentityResolver from "./RepositoryIdentityResolver.ts";
const normalizePathSeparators = (value: string) => value.replaceAll("\\", "/");
const normalizeResolvedPath = (value: string) => normalizePathSeparators(value);
@@ -31,8 +27,8 @@ const makeRepositoryIdentityResolverTestLayer = (options: {
readonly negativeCacheTtl?: Duration.Input;
}) =>
Layer.effect(
- RepositoryIdentityResolver,
- makeRepositoryIdentityResolver({
+ RepositoryIdentityResolver.RepositoryIdentityResolver,
+ RepositoryIdentityResolver.make({
cacheCapacity: 16,
...options,
}),
@@ -49,7 +45,7 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => {
yield* git(cwd, ["init"]);
yield* git(cwd, ["remote", "add", "origin", "git@github.com:T3Tools/t3code.git"]);
- const resolver = yield* RepositoryIdentityResolver;
+ const resolver = yield* RepositoryIdentityResolver.RepositoryIdentityResolver;
const identity = yield* resolver.resolve(cwd);
const resolvedIdentityRoot =
identity?.rootPath === undefined ? "" : yield* fileSystem.realPath(identity.rootPath);
@@ -62,7 +58,7 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => {
expect(identity?.provider).toBe("github");
expect(identity?.owner).toBe("t3tools");
expect(identity?.name).toBe("t3code");
- }).pipe(Effect.provide(RepositoryIdentityResolverLive)),
+ }).pipe(Effect.provide(RepositoryIdentityResolver.layer)),
);
it.effect("returns the git top-level root path when resolving from a nested workspace", () =>
@@ -78,7 +74,7 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => {
yield* git(repoRoot, ["init"]);
yield* git(repoRoot, ["remote", "add", "origin", "git@github.com:T3Tools/t3code.git"]);
- const resolver = yield* RepositoryIdentityResolver;
+ const resolver = yield* RepositoryIdentityResolver.RepositoryIdentityResolver;
const identity = yield* resolver.resolve(nestedWorkspace);
const resolvedIdentityRoot =
identity?.rootPath === undefined ? "" : yield* fileSystem.realPath(identity.rootPath);
@@ -89,7 +85,7 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => {
expect(normalizeResolvedPath(resolvedIdentityRoot)).toBe(
normalizeResolvedPath(resolvedRepoRoot),
);
- }).pipe(Effect.provide(RepositoryIdentityResolverLive)),
+ }).pipe(Effect.provide(RepositoryIdentityResolver.layer)),
);
it.effect("returns null for non-git folders and repos without remotes", () =>
@@ -104,13 +100,13 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => {
yield* git(gitDir, ["init"]);
- const resolver = yield* RepositoryIdentityResolver;
+ const resolver = yield* RepositoryIdentityResolver.RepositoryIdentityResolver;
const nonGitIdentity = yield* resolver.resolve(nonGitDir);
const noRemoteIdentity = yield* resolver.resolve(gitDir);
expect(nonGitIdentity).toBeNull();
expect(noRemoteIdentity).toBeNull();
- }).pipe(Effect.provide(RepositoryIdentityResolverLive)),
+ }).pipe(Effect.provide(RepositoryIdentityResolver.layer)),
);
it.effect("prefers upstream over origin when both remotes are configured", () =>
@@ -124,14 +120,14 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => {
yield* git(cwd, ["remote", "add", "origin", "git@github.com:julius/t3code.git"]);
yield* git(cwd, ["remote", "add", "upstream", "git@github.com:T3Tools/t3code.git"]);
- const resolver = yield* RepositoryIdentityResolver;
+ const resolver = yield* RepositoryIdentityResolver.RepositoryIdentityResolver;
const identity = yield* resolver.resolve(cwd);
expect(identity).not.toBeNull();
expect(identity?.locator.remoteName).toBe("upstream");
expect(identity?.canonicalKey).toBe("github.com/t3tools/t3code");
expect(identity?.displayName).toBe("t3tools/t3code");
- }).pipe(Effect.provide(RepositoryIdentityResolverLive)),
+ }).pipe(Effect.provide(RepositoryIdentityResolver.layer)),
);
it.effect("uses the last remote path segment as the repository name for nested groups", () =>
@@ -144,7 +140,7 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => {
yield* git(cwd, ["init"]);
yield* git(cwd, ["remote", "add", "origin", "git@gitlab.com:T3Tools/platform/t3code.git"]);
- const resolver = yield* RepositoryIdentityResolver;
+ const resolver = yield* RepositoryIdentityResolver.RepositoryIdentityResolver;
const identity = yield* resolver.resolve(cwd);
expect(identity).not.toBeNull();
@@ -152,7 +148,7 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => {
expect(identity?.displayName).toBe("t3tools/platform/t3code");
expect(identity?.owner).toBe("t3tools");
expect(identity?.name).toBe("t3code");
- }).pipe(Effect.provide(RepositoryIdentityResolverLive)),
+ }).pipe(Effect.provide(RepositoryIdentityResolver.layer)),
);
it.effect(
@@ -166,7 +162,7 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => {
yield* git(cwd, ["init"]);
- const resolver = yield* RepositoryIdentityResolver;
+ const resolver = yield* RepositoryIdentityResolver.RepositoryIdentityResolver;
const initialIdentity = yield* resolver.resolve(cwd);
expect(initialIdentity).toBeNull();
@@ -206,7 +202,7 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => {
yield* git(cwd, ["init"]);
yield* git(cwd, ["remote", "add", "origin", "git@github.com:T3Tools/t3code.git"]);
- const resolver = yield* RepositoryIdentityResolver;
+ const resolver = yield* RepositoryIdentityResolver.RepositoryIdentityResolver;
const initialIdentity = yield* resolver.resolve(cwd);
expect(initialIdentity).not.toBeNull();
expect(initialIdentity?.canonicalKey).toBe("github.com/t3tools/t3code");
diff --git a/apps/server/src/project/Layers/RepositoryIdentityResolver.ts b/apps/server/src/project/RepositoryIdentityResolver.ts
similarity index 53%
rename from apps/server/src/project/Layers/RepositoryIdentityResolver.ts
rename to apps/server/src/project/RepositoryIdentityResolver.ts
index d4ae073b953..50608e7704c 100644
--- a/apps/server/src/project/Layers/RepositoryIdentityResolver.ts
+++ b/apps/server/src/project/RepositoryIdentityResolver.ts
@@ -1,19 +1,33 @@
import type { RepositoryIdentity } from "@t3tools/contracts";
+import {
+ detectSourceControlProviderFromGitRemoteUrl,
+ normalizeGitRemoteUrl,
+} from "@t3tools/shared/git";
import * as Cache from "effect/Cache";
+import * as Context from "effect/Context";
import * as Duration from "effect/Duration";
import * as Effect from "effect/Effect";
import * as Exit from "effect/Exit";
import * as Layer from "effect/Layer";
-import {
- detectSourceControlProviderFromGitRemoteUrl,
- normalizeGitRemoteUrl,
-} from "@t3tools/shared/git";
-import * as ProcessRunner from "../../processRunner.ts";
-import {
+import * as ProcessRunner from "../processRunner.ts";
+
+const DEFAULT_REPOSITORY_IDENTITY_CACHE_CAPACITY = 512;
+const DEFAULT_POSITIVE_CACHE_TTL = Duration.minutes(1);
+const DEFAULT_NEGATIVE_CACHE_TTL = Duration.minutes(1);
+
+export interface RepositoryIdentityResolverOptions {
+ readonly cacheCapacity?: number;
+ readonly positiveCacheTtl?: Duration.Input;
+ readonly negativeCacheTtl?: Duration.Input;
+}
+
+export class RepositoryIdentityResolver extends Context.Service<
RepositoryIdentityResolver,
- type RepositoryIdentityResolverShape,
-} from "../Services/RepositoryIdentityResolver.ts";
+ {
+ readonly resolve: (cwd: string) => Effect.Effect;
+ }
+>()("t3/project/RepositoryIdentityResolver") {}
function parseRemoteFetchUrls(stdout: string): Map {
const remotes = new Map();
@@ -73,101 +87,88 @@ function buildRepositoryIdentity(input: {
};
}
-const DEFAULT_REPOSITORY_IDENTITY_CACHE_CAPACITY = 512;
-const DEFAULT_POSITIVE_CACHE_TTL = Duration.minutes(1);
-const DEFAULT_NEGATIVE_CACHE_TTL = Duration.minutes(1);
+const resolveRepositoryIdentityCacheKey = Effect.fn("RepositoryIdentityResolver.resolveCacheKey")(
+ function* (cwd: string) {
+ const processRunner = yield* ProcessRunner.ProcessRunner;
+ let cacheKey = cwd;
-interface RepositoryIdentityResolverOptions {
- readonly cacheCapacity?: number;
- readonly positiveCacheTtl?: Duration.Input;
- readonly negativeCacheTtl?: Duration.Input;
-}
+ // git is a real executable on every platform — no cmd.exe shell mode, which
+ // would split paths containing spaces during cmd's re-tokenization.
+ const topLevelResult = yield* processRunner
+ .run({
+ command: "git",
+ args: ["-C", cwd, "rev-parse", "--show-toplevel"],
+ timeoutBehavior: "timedOutResult",
+ })
+ .pipe(Effect.option);
+ if (topLevelResult._tag === "None" || topLevelResult.value.code !== 0) {
+ return cacheKey;
+ }
-const resolveRepositoryIdentityCacheKey = Effect.fn("resolveRepositoryIdentityCacheKey")(function* (
- cwd: string,
-) {
- const processRunner = yield* ProcessRunner.ProcessRunner;
- let cacheKey = cwd;
+ const candidate = topLevelResult.value.stdout.trim();
+ if (candidate.length > 0) {
+ cacheKey = candidate;
+ }
+
+ return cacheKey;
+ },
+);
- // git is a real executable on every platform — no cmd.exe shell mode, which
- // would split paths containing spaces during cmd's re-tokenization.
- const topLevelResult = yield* processRunner
+const resolveRepositoryIdentityFromCacheKey = Effect.fn(
+ "RepositoryIdentityResolver.resolveFromCacheKey",
+)(function* (
+ cacheKey: string,
+): Effect.fn.Return {
+ const processRunner = yield* ProcessRunner.ProcessRunner;
+ const remoteResult = yield* processRunner
.run({
command: "git",
- args: ["-C", cwd, "rev-parse", "--show-toplevel"],
+ args: ["-C", cacheKey, "remote", "-v"],
timeoutBehavior: "timedOutResult",
})
.pipe(Effect.option);
- if (topLevelResult._tag === "None" || topLevelResult.value.code !== 0) {
- return cacheKey;
- }
-
- const candidate = topLevelResult.value.stdout.trim();
- if (candidate.length > 0) {
- cacheKey = candidate;
+ if (remoteResult._tag === "None" || remoteResult.value.code !== 0) {
+ return null;
}
- return cacheKey;
+ const remote = pickPrimaryRemote(parseRemoteFetchUrls(remoteResult.value.stdout));
+ return remote ? buildRepositoryIdentity({ ...remote, rootPath: cacheKey }) : null;
});
-const resolveRepositoryIdentityFromCacheKey = Effect.fn("resolveRepositoryIdentityFromCacheKey")(
- function* (
- cacheKey: string,
- ): Effect.fn.Return {
- const processRunner = yield* ProcessRunner.ProcessRunner;
- const remoteResult = yield* processRunner
- .run({
- command: "git",
- args: ["-C", cacheKey, "remote", "-v"],
- timeoutBehavior: "timedOutResult",
- })
- .pipe(Effect.option);
- if (remoteResult._tag === "None" || remoteResult.value.code !== 0) {
- return null;
- }
-
- const remote = pickPrimaryRemote(parseRemoteFetchUrls(remoteResult.value.stdout));
- return remote ? buildRepositoryIdentity({ ...remote, rootPath: cacheKey }) : null;
- },
-);
+export const make = Effect.fn("RepositoryIdentityResolver.make")(function* (
+ options: RepositoryIdentityResolverOptions = {},
+) {
+ const processRunner = yield* ProcessRunner.ProcessRunner;
-export const makeRepositoryIdentityResolver = Effect.fn("makeRepositoryIdentityResolver")(
- function* (options: RepositoryIdentityResolverOptions = {}) {
- const processRunner = yield* ProcessRunner.ProcessRunner;
+ const repositoryIdentityCache = yield* Cache.makeWith(
+ (cacheKey) =>
+ resolveRepositoryIdentityFromCacheKey(cacheKey).pipe(
+ Effect.provideService(ProcessRunner.ProcessRunner, processRunner),
+ ),
+ {
+ capacity: options.cacheCapacity ?? DEFAULT_REPOSITORY_IDENTITY_CACHE_CAPACITY,
+ timeToLive: Exit.match({
+ onSuccess: (value) =>
+ value === null
+ ? (options.negativeCacheTtl ?? DEFAULT_NEGATIVE_CACHE_TTL)
+ : (options.positiveCacheTtl ?? DEFAULT_POSITIVE_CACHE_TTL),
+ onFailure: () => Duration.zero,
+ }),
+ },
+ );
- const repositoryIdentityCache = yield* Cache.makeWith(
- (cacheKey) =>
- resolveRepositoryIdentityFromCacheKey(cacheKey).pipe(
- Effect.provideService(ProcessRunner.ProcessRunner, processRunner),
- ),
- {
- capacity: options.cacheCapacity ?? DEFAULT_REPOSITORY_IDENTITY_CACHE_CAPACITY,
- timeToLive: Exit.match({
- onSuccess: (value) =>
- value === null
- ? (options.negativeCacheTtl ?? DEFAULT_NEGATIVE_CACHE_TTL)
- : (options.positiveCacheTtl ?? DEFAULT_POSITIVE_CACHE_TTL),
- onFailure: () => Duration.zero,
- }),
- },
+ const resolve: RepositoryIdentityResolver["Service"]["resolve"] = Effect.fn(
+ "RepositoryIdentityResolver.resolve",
+ )(function* (cwd) {
+ const cacheKey = yield* resolveRepositoryIdentityCacheKey(cwd).pipe(
+ Effect.provideService(ProcessRunner.ProcessRunner, processRunner),
);
+ return yield* Cache.get(repositoryIdentityCache, cacheKey);
+ });
- const resolve: RepositoryIdentityResolverShape["resolve"] = Effect.fn(
- "RepositoryIdentityResolver.resolve",
- )(function* (cwd) {
- const cacheKey = yield* resolveRepositoryIdentityCacheKey(cwd).pipe(
- Effect.provideService(ProcessRunner.ProcessRunner, processRunner),
- );
- return yield* Cache.get(repositoryIdentityCache, cacheKey);
- });
+ return RepositoryIdentityResolver.of({ resolve });
+});
- return {
- resolve,
- } satisfies RepositoryIdentityResolverShape;
- },
+export const layer = Layer.effect(RepositoryIdentityResolver, make()).pipe(
+ Layer.provide(ProcessRunner.layer),
);
-
-export const RepositoryIdentityResolverLive = Layer.effect(
- RepositoryIdentityResolver,
- makeRepositoryIdentityResolver(),
-).pipe(Layer.provide(ProcessRunner.layer));
diff --git a/apps/server/src/project/Services/ProjectFaviconResolver.ts b/apps/server/src/project/Services/ProjectFaviconResolver.ts
deleted file mode 100644
index ad1b466e2c7..00000000000
--- a/apps/server/src/project/Services/ProjectFaviconResolver.ts
+++ /dev/null
@@ -1,30 +0,0 @@
-/**
- * ProjectFaviconResolver - Effect service contract for project icon discovery.
- *
- * Resolves a representative favicon or app icon file for a workspace by
- * checking common file locations and project source metadata.
- *
- * @module ProjectFaviconResolver
- */
-import * as Context from "effect/Context";
-import type * as Effect from "effect/Effect";
-
-/**
- * ProjectFaviconResolverShape - Service API for project favicon lookup.
- */
-export interface ProjectFaviconResolverShape {
- /**
- * Resolve a favicon or icon file path for the provided workspace root.
- *
- * Returns `null` when no candidate icon file can be found.
- */
- readonly resolvePath: (cwd: string) => Effect.Effect;
-}
-
-/**
- * ProjectFaviconResolver - Service tag for project favicon resolution.
- */
-export class ProjectFaviconResolver extends Context.Service<
- ProjectFaviconResolver,
- ProjectFaviconResolverShape
->()("t3/project/Services/ProjectFaviconResolver") {}
diff --git a/apps/server/src/project/Services/ProjectSetupScriptRunner.ts b/apps/server/src/project/Services/ProjectSetupScriptRunner.ts
deleted file mode 100644
index 17168eda7f1..00000000000
--- a/apps/server/src/project/Services/ProjectSetupScriptRunner.ts
+++ /dev/null
@@ -1,44 +0,0 @@
-import * as Context from "effect/Context";
-import * as Data from "effect/Data";
-import type * as Effect from "effect/Effect";
-
-export interface ProjectSetupScriptRunnerResultNoScript {
- readonly status: "no-script";
-}
-
-export interface ProjectSetupScriptRunnerResultStarted {
- readonly status: "started";
- readonly scriptId: string;
- readonly scriptName: string;
- readonly terminalId: string;
- readonly cwd: string;
-}
-
-export type ProjectSetupScriptRunnerResult =
- | ProjectSetupScriptRunnerResultNoScript
- | ProjectSetupScriptRunnerResultStarted;
-
-export interface ProjectSetupScriptRunnerInput {
- readonly threadId: string;
- readonly projectId?: string;
- readonly projectCwd?: string;
- readonly worktreePath: string;
- readonly preferredTerminalId?: string;
-}
-
-export class ProjectSetupScriptRunnerError extends Data.TaggedError(
- "ProjectSetupScriptRunnerError",
-)<{
- readonly message: string;
-}> {}
-
-export interface ProjectSetupScriptRunnerShape {
- readonly runForThread: (
- input: ProjectSetupScriptRunnerInput,
- ) => Effect.Effect;
-}
-
-export class ProjectSetupScriptRunner extends Context.Service<
- ProjectSetupScriptRunner,
- ProjectSetupScriptRunnerShape
->()("t3/project/Services/ProjectSetupScriptRunner") {}
diff --git a/apps/server/src/project/Services/RepositoryIdentityResolver.ts b/apps/server/src/project/Services/RepositoryIdentityResolver.ts
deleted file mode 100644
index ef0b128c6f7..00000000000
--- a/apps/server/src/project/Services/RepositoryIdentityResolver.ts
+++ /dev/null
@@ -1,12 +0,0 @@
-import type { RepositoryIdentity } from "@t3tools/contracts";
-import * as Context from "effect/Context";
-import type * as Effect from "effect/Effect";
-
-export interface RepositoryIdentityResolverShape {
- readonly resolve: (cwd: string) => Effect.Effect;
-}
-
-export class RepositoryIdentityResolver extends Context.Service<
- RepositoryIdentityResolver,
- RepositoryIdentityResolverShape
->()("t3/project/Services/RepositoryIdentityResolver") {}
diff --git a/apps/server/src/relay/AgentAwarenessRelay.test.ts b/apps/server/src/relay/AgentAwarenessRelay.test.ts
index 4d31bb26137..eb2b2c3f2fa 100644
--- a/apps/server/src/relay/AgentAwarenessRelay.test.ts
+++ b/apps/server/src/relay/AgentAwarenessRelay.test.ts
@@ -1,4 +1,4 @@
-import * as NodeCrypto from "node:crypto";
+import { generateKeyPairSync } from "node:crypto";
import * as NodeServices from "@effect/platform-node/NodeServices";
import type {
@@ -29,7 +29,7 @@ import * as Stream from "effect/Stream";
import * as Tracer from "effect/Tracer";
import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
-import { ServerEnvironment } from "../environment/Services/ServerEnvironment.ts";
+import * as ServerEnvironment from "../environment/ServerEnvironment.ts";
import {
OrchestrationEngineService,
type OrchestrationEngineShape,
@@ -348,7 +348,7 @@ describe.sequential("signRelayAgentActivityPublishProof", () => {
});
it("signs the activity publish JWT and rejects tampering", async () => {
- const keyPair = NodeCrypto.generateKeyPairSync("ed25519", {
+ const keyPair = generateKeyPairSync("ed25519", {
privateKeyEncoding: { format: "pem", type: "pkcs8" },
publicKeyEncoding: { format: "pem", type: "spki" },
});
@@ -494,7 +494,7 @@ describe.sequential("signRelayAgentActivityPublishProof", () => {
const layer = Layer.mergeAll(
Layer.succeed(ServerSecretStore.ServerSecretStore, secrets.store),
- Layer.succeed(ServerEnvironment, {
+ Layer.succeed(ServerEnvironment.ServerEnvironment, {
getEnvironmentId: Effect.succeed(environmentId),
getDescriptor: Effect.succeed(descriptor),
}),
@@ -642,7 +642,7 @@ describe.sequential("signRelayAgentActivityPublishProof", () => {
const layer = Layer.mergeAll(
Layer.succeed(ServerSecretStore.ServerSecretStore, secrets.store),
- Layer.succeed(ServerEnvironment, {
+ Layer.succeed(ServerEnvironment.ServerEnvironment, {
getEnvironmentId: Effect.succeed(environmentId),
getDescriptor: Effect.succeed(descriptor),
}),
diff --git a/apps/server/src/relay/AgentAwarenessRelay.ts b/apps/server/src/relay/AgentAwarenessRelay.ts
index 8528b4b0c8e..4e036e3ea0e 100644
--- a/apps/server/src/relay/AgentAwarenessRelay.ts
+++ b/apps/server/src/relay/AgentAwarenessRelay.ts
@@ -41,7 +41,7 @@ import {
RELAY_URL_SECRET,
} from "../cloud/config.ts";
import { getOrCreateEnvironmentKeyPairFromSecretStore } from "../cloud/environmentKeys.ts";
-import * as ServerEnvironment from "../environment/Services/ServerEnvironment.ts";
+import * as ServerEnvironment from "../environment/ServerEnvironment.ts";
import * as OrchestrationEngine from "../orchestration/Services/OrchestrationEngine.ts";
import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts";
diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts
index 76824af73e3..fd69c610df4 100644
--- a/apps/server/src/server.test.ts
+++ b/apps/server/src/server.test.ts
@@ -1,7 +1,7 @@
import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer";
import * as NodeSocket from "@effect/platform-node/NodeSocket";
import * as NodeServices from "@effect/platform-node/NodeServices";
-import * as NodeCrypto from "node:crypto";
+import { generateKeyPairSync, type KeyObject, sign } from "node:crypto";
import {
AuthAccessTokenType,
@@ -89,13 +89,13 @@ import * as TerminalManager from "./terminal/Manager.ts";
import * as PreviewManager from "./preview/Manager.ts";
import * as PortScanner from "./preview/PortScanner.ts";
import * as BrowserTraceCollector from "./observability/BrowserTraceCollector.ts";
-import { ProjectFaviconResolverLive } from "./project/Layers/ProjectFaviconResolver.ts";
-import * as ProjectSetupScriptRunner from "./project/Services/ProjectSetupScriptRunner.ts";
-import * as RepositoryIdentityResolver from "./project/Services/RepositoryIdentityResolver.ts";
-import * as ServerEnvironment from "./environment/Services/ServerEnvironment.ts";
+import * as ProjectFaviconResolver from "./project/ProjectFaviconResolver.ts";
+import * as ProjectSetupScriptRunner from "./project/ProjectSetupScriptRunner.ts";
+import * as RepositoryIdentityResolver from "./project/RepositoryIdentityResolver.ts";
+import * as ServerEnvironment from "./environment/ServerEnvironment.ts";
import * as WorkspaceEntries from "./workspace/WorkspaceEntries.ts";
-import { WorkspaceFileSystemLive } from "./workspace/Layers/WorkspaceFileSystem.ts";
-import { WorkspacePathsLive } from "./workspace/Layers/WorkspacePaths.ts";
+import * as WorkspaceFileSystem from "./workspace/WorkspaceFileSystem.ts";
+import * as WorkspacePaths from "./workspace/WorkspacePaths.ts";
import * as GitVcsDriver from "./vcs/GitVcsDriver.ts";
import * as VcsDriver from "./vcs/VcsDriver.ts";
import * as VcsStatusBroadcaster from "./vcs/VcsStatusBroadcaster.ts";
@@ -485,17 +485,17 @@ const buildAppUnderTest = (options?: {
...options?.layers?.gitManager,
});
const workspaceEntriesLayer = WorkspaceEntries.layer.pipe(
- Layer.provide(WorkspacePathsLive),
+ Layer.provide(WorkspacePaths.layer),
Layer.provideMerge(vcsDriverRegistryLayer),
);
const workspaceAndProjectServicesLayer = Layer.mergeAll(
- WorkspacePathsLive,
+ WorkspacePaths.layer,
workspaceEntriesLayer,
- WorkspaceFileSystemLive.pipe(
- Layer.provide(WorkspacePathsLive),
+ WorkspaceFileSystem.layer.pipe(
+ Layer.provide(WorkspacePaths.layer),
Layer.provide(workspaceEntriesLayer),
),
- ProjectFaviconResolverLive.pipe(Layer.provide(WorkspacePathsLive)),
+ ProjectFaviconResolver.layer.pipe(Layer.provide(WorkspacePaths.layer)),
);
const gitWorkflowLayer = GitWorkflowService.layer.pipe(
Layer.provideMerge(vcsDriverRegistryLayer),
@@ -950,14 +950,14 @@ const makeDpopProof = (input: {
readonly iat: number;
readonly accessToken?: string;
readonly jti?: string;
- readonly privateKey?: NodeCrypto.KeyObject;
+ readonly privateKey?: KeyObject;
readonly publicJwk?: DpopPublicJwk;
}) => {
const keyPair =
input.privateKey && input.publicJwk
? { privateKey: input.privateKey, publicJwk: input.publicJwk }
: (() => {
- const { privateKey, publicKey } = NodeCrypto.generateKeyPairSync("ec", {
+ const { privateKey, publicKey } = generateKeyPairSync("ec", {
namedCurve: "P-256",
});
return { privateKey, publicJwk: publicKey.export({ format: "jwk" }) as DpopPublicJwk };
@@ -978,7 +978,7 @@ const makeDpopProof = (input: {
...(input.accessToken ? { ath: computeDpopAccessTokenHash(input.accessToken) } : {}),
}),
).toString("base64url");
- const signature = NodeCrypto.sign("sha256", Buffer.from(`${header}.${payload}`), {
+ const signature = sign("sha256", Buffer.from(`${header}.${payload}`), {
key: keyPair.privateKey,
dsaEncoding: "ieee-p1363",
}).toString("base64url");
@@ -1024,7 +1024,7 @@ const makeCloudMintCredentialRequest = (input: {
const encodedPayload = Buffer.from(JSON.stringify(payload)).toString("base64url");
const signingInput = `${header}.${encodedPayload}`;
return {
- proof: `${signingInput}.${NodeCrypto.sign(null, Buffer.from(signingInput), input.privateKey).toString("base64url")}`,
+ proof: `${signingInput}.${sign(null, Buffer.from(signingInput), input.privateKey).toString("base64url")}`,
};
};
@@ -1057,7 +1057,7 @@ const makeCloudEnvironmentHealthRequest = (input: {
const encodedPayload = Buffer.from(JSON.stringify(payload)).toString("base64url");
const signingInput = `${header}.${encodedPayload}`;
return {
- proof: `${signingInput}.${NodeCrypto.sign(null, Buffer.from(signingInput), input.privateKey).toString("base64url")}`,
+ proof: `${signingInput}.${sign(null, Buffer.from(signingInput), input.privateKey).toString("base64url")}`,
};
};
@@ -2054,7 +2054,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
Effect.gen(function* () {
yield* buildAppUnderTest();
- const cloudKeyPair = NodeCrypto.generateKeyPairSync("ed25519", {
+ const cloudKeyPair = generateKeyPairSync("ed25519", {
privateKeyEncoding: { format: "pem", type: "pkcs8" },
publicKeyEncoding: { format: "pem", type: "spki" },
});
@@ -2131,7 +2131,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
Effect.gen(function* () {
yield* buildAppUnderTest();
- const cloudKeyPair = NodeCrypto.generateKeyPairSync("ed25519", {
+ const cloudKeyPair = generateKeyPairSync("ed25519", {
privateKeyEncoding: { format: "pem", type: "pkcs8" },
publicKeyEncoding: { format: "pem", type: "spki" },
});
@@ -2174,7 +2174,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
Effect.gen(function* () {
yield* buildAppUnderTest();
- const cloudKeyPair = NodeCrypto.generateKeyPairSync("ed25519", {
+ const cloudKeyPair = generateKeyPairSync("ed25519", {
privateKeyEncoding: { format: "pem", type: "pkcs8" },
publicKeyEncoding: { format: "pem", type: "spki" },
});
@@ -2268,7 +2268,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
},
});
- const cloudKeyPair = NodeCrypto.generateKeyPairSync("ed25519", {
+ const cloudKeyPair = generateKeyPairSync("ed25519", {
privateKeyEncoding: { format: "pem", type: "pkcs8" },
publicKeyEncoding: { format: "pem", type: "spki" },
});
@@ -2345,7 +2345,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
Effect.gen(function* () {
yield* buildAppUnderTest();
- const cloudKeyPair = NodeCrypto.generateKeyPairSync("ed25519", {
+ const cloudKeyPair = generateKeyPairSync("ed25519", {
privateKeyEncoding: { format: "pem", type: "pkcs8" },
publicKeyEncoding: { format: "pem", type: "spki" },
});
@@ -2404,7 +2404,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
Effect.gen(function* () {
yield* buildAppUnderTest();
- const cloudKeyPair = NodeCrypto.generateKeyPairSync("ed25519", {
+ const cloudKeyPair = generateKeyPairSync("ed25519", {
privateKeyEncoding: { format: "pem", type: "pkcs8" },
publicKeyEncoding: { format: "pem", type: "spki" },
});
@@ -2463,7 +2463,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
Effect.gen(function* () {
yield* buildAppUnderTest();
- const cloudKeyPair = NodeCrypto.generateKeyPairSync("ed25519", {
+ const cloudKeyPair = generateKeyPairSync("ed25519", {
privateKeyEncoding: { format: "pem", type: "pkcs8" },
publicKeyEncoding: { format: "pem", type: "spki" },
});
@@ -2523,7 +2523,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
Effect.gen(function* () {
yield* buildAppUnderTest();
- const cloudKeyPair = NodeCrypto.generateKeyPairSync("ed25519", {
+ const cloudKeyPair = generateKeyPairSync("ed25519", {
privateKeyEncoding: { format: "pem", type: "pkcs8" },
publicKeyEncoding: { format: "pem", type: "spki" },
});
@@ -2584,7 +2584,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
Effect.gen(function* () {
yield* buildAppUnderTest();
- const cloudKeyPair = NodeCrypto.generateKeyPairSync("ed25519", {
+ const cloudKeyPair = generateKeyPairSync("ed25519", {
privateKeyEncoding: { format: "pem", type: "pkcs8" },
publicKeyEncoding: { format: "pem", type: "spki" },
});
@@ -2664,7 +2664,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
},
});
- const cloudKeyPair = NodeCrypto.generateKeyPairSync("ed25519", {
+ const cloudKeyPair = generateKeyPairSync("ed25519", {
privateKeyEncoding: { format: "pem", type: "pkcs8" },
publicKeyEncoding: { format: "pem", type: "spki" },
});
@@ -2733,7 +2733,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
Effect.gen(function* () {
yield* buildAppUnderTest();
- const cloudKeyPair = NodeCrypto.generateKeyPairSync("ed25519", {
+ const cloudKeyPair = generateKeyPairSync("ed25519", {
privateKeyEncoding: { format: "pem", type: "pkcs8" },
publicKeyEncoding: { format: "pem", type: "spki" },
});
@@ -2800,7 +2800,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
Effect.gen(function* () {
yield* buildAppUnderTest();
- const cloudKeyPair = NodeCrypto.generateKeyPairSync("ed25519", {
+ const cloudKeyPair = generateKeyPairSync("ed25519", {
privateKeyEncoding: { format: "pem", type: "pkcs8" },
publicKeyEncoding: { format: "pem", type: "spki" },
});
@@ -2851,7 +2851,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
Effect.gen(function* () {
yield* buildAppUnderTest();
- const cloudKeyPair = NodeCrypto.generateKeyPairSync("ed25519", {
+ const cloudKeyPair = generateKeyPairSync("ed25519", {
privateKeyEncoding: { format: "pem", type: "pkcs8" },
publicKeyEncoding: { format: "pem", type: "spki" },
});
@@ -2902,7 +2902,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
Effect.gen(function* () {
yield* buildAppUnderTest();
- const cloudKeyPair = NodeCrypto.generateKeyPairSync("ed25519", {
+ const cloudKeyPair = generateKeyPairSync("ed25519", {
privateKeyEncoding: { format: "pem", type: "pkcs8" },
publicKeyEncoding: { format: "pem", type: "spki" },
});
@@ -2967,7 +2967,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
Effect.gen(function* () {
yield* buildAppUnderTest();
- const cloudKeyPair = NodeCrypto.generateKeyPairSync("ed25519", {
+ const cloudKeyPair = generateKeyPairSync("ed25519", {
privateKeyEncoding: { format: "pem", type: "pkcs8" },
publicKeyEncoding: { format: "pem", type: "spki" },
});
@@ -3017,7 +3017,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
Effect.gen(function* () {
yield* buildAppUnderTest();
- const cloudKeyPair = NodeCrypto.generateKeyPairSync("ed25519", {
+ const cloudKeyPair = generateKeyPairSync("ed25519", {
privateKeyEncoding: { format: "pem", type: "pkcs8" },
publicKeyEncoding: { format: "pem", type: "spki" },
});
@@ -4447,10 +4447,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
assertTrue(result._tag === "Failure");
assertTrue(result.failure._tag === "ProjectSearchEntriesError");
- assertInclude(
- result.failure.message,
- "Workspace root does not exist: /definitely/not/a/real/workspace/path",
- );
+ assert.equal(result.failure.message, "Failed to search workspace entries.");
}).pipe(Effect.provide(NodeHttpServer.layerTest)),
);
@@ -6096,13 +6093,16 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
);
const runForThread = vi.fn(
(
- _: Parameters<
+ input: Parameters<
ProjectSetupScriptRunner.ProjectSetupScriptRunner["Service"]["runForThread"]
>[0],
) =>
Effect.fail(
- new ProjectSetupScriptRunner.ProjectSetupScriptRunnerError({
- message: "pty unavailable",
+ new ProjectSetupScriptRunner.ProjectSetupScriptOperationError({
+ threadId: input.threadId,
+ worktreePath: input.worktreePath,
+ operation: "openTerminal",
+ cause: new Error("pty unavailable"),
}),
),
);
@@ -6177,7 +6177,8 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
);
assert.equal(setupFailureActivity?.activity.kind, "setup-script.failed");
assert.deepEqual(setupFailureActivity?.activity.payload, {
- detail: "pty unavailable",
+ detail:
+ "Project setup script operation 'openTerminal' failed for thread 'thread-bootstrap-setup-failure' in '/tmp/bootstrap-worktree'.",
worktreePath: "/tmp/bootstrap-worktree",
});
assertTrue(dispatchedCommands.every((command) => command.type !== "thread.delete"));
diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts
index 987ba83deae..81d0013b20c 100644
--- a/apps/server/src/server.ts
+++ b/apps/server/src/server.ts
@@ -52,11 +52,11 @@ import * as AgentAwarenessRelay from "./relay/AgentAwarenessRelay.ts";
import { hasCloudPublicConfig } from "./cloud/publicConfig.ts";
import { ProviderRegistryLive } from "./provider/Layers/ProviderRegistry.ts";
import * as ServerSettings from "./serverSettings.ts";
-import { ProjectFaviconResolverLive } from "./project/Layers/ProjectFaviconResolver.ts";
-import { RepositoryIdentityResolverLive } from "./project/Layers/RepositoryIdentityResolver.ts";
+import * as ProjectFaviconResolver from "./project/ProjectFaviconResolver.ts";
+import * as RepositoryIdentityResolver from "./project/RepositoryIdentityResolver.ts";
import * as WorkspaceEntries from "./workspace/WorkspaceEntries.ts";
-import { WorkspaceFileSystemLive } from "./workspace/Layers/WorkspaceFileSystem.ts";
-import { WorkspacePathsLive } from "./workspace/Layers/WorkspacePaths.ts";
+import * as WorkspaceFileSystem from "./workspace/WorkspaceFileSystem.ts";
+import * as WorkspacePaths from "./workspace/WorkspacePaths.ts";
import * as GitVcsDriver from "./vcs/GitVcsDriver.ts";
import * as VcsDriverRegistry from "./vcs/VcsDriverRegistry.ts";
import * as VcsProjectConfig from "./vcs/VcsProjectConfig.ts";
@@ -67,9 +67,9 @@ import * as GitWorkflowService from "./git/GitWorkflowService.ts";
import * as ReviewService from "./review/ReviewService.ts";
import * as SourceControlProviderRegistry from "./sourceControl/SourceControlProviderRegistry.ts";
import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts";
-import { ProjectSetupScriptRunnerLive } from "./project/Layers/ProjectSetupScriptRunner.ts";
+import * as ProjectSetupScriptRunner from "./project/ProjectSetupScriptRunner.ts";
import { ObservabilityLive } from "./observability/Layers/Observability.ts";
-import { ServerEnvironmentLive } from "./environment/Layers/ServerEnvironment.ts";
+import * as ServerEnvironment from "./environment/ServerEnvironment.ts";
import { authHttpApiLayer, environmentAuthenticatedAuthLayer } from "./auth/http.ts";
import * as ServerSecretStore from "./auth/ServerSecretStore.ts";
import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts";
@@ -195,7 +195,7 @@ const SourceControlProviderRegistryLayerLive = SourceControlProviderRegistry.lay
);
const GitManagerLayerLive = GitManager.layer.pipe(
- Layer.provideMerge(ProjectSetupScriptRunnerLive),
+ Layer.provideMerge(ProjectSetupScriptRunner.layer),
Layer.provideMerge(GitVcsDriver.layer),
Layer.provideMerge(SourceControlProviderRegistryLayerLive),
Layer.provideMerge(TextGeneration.layer),
@@ -248,21 +248,21 @@ const PreviewLayerLive = Layer.empty.pipe(
Layer.provideMerge(PortScannerLayerLive),
);
-const WorkspaceEntriesLayerLive = WorkspaceEntries.layer.pipe(Layer.provide(WorkspacePathsLive));
+const WorkspaceEntriesLayerLive = WorkspaceEntries.layer.pipe(Layer.provide(WorkspacePaths.layer));
-const WorkspaceFileSystemLayerLive = WorkspaceFileSystemLive.pipe(
- Layer.provide(WorkspacePathsLive),
+const WorkspaceFileSystemLayerLive = WorkspaceFileSystem.layer.pipe(
+ Layer.provide(WorkspacePaths.layer),
Layer.provide(WorkspaceEntriesLayerLive),
);
const WorkspaceLayerLive = Layer.mergeAll(
- WorkspacePathsLive,
+ WorkspacePaths.layer,
WorkspaceEntriesLayerLive,
WorkspaceFileSystemLayerLive,
);
-const ProjectFaviconResolverLayerLive = ProjectFaviconResolverLive.pipe(
- Layer.provide(WorkspacePathsLive),
+const ProjectFaviconResolverLayerLive = ProjectFaviconResolver.layer.pipe(
+ Layer.provide(WorkspacePaths.layer),
);
const AuthLayerLive = EnvironmentAuth.layer.pipe(
@@ -315,8 +315,8 @@ const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe(
Layer.provideMerge(ServerSettings.layer.pipe(Layer.provide(ServerSecretStore.layer))),
Layer.provideMerge(WorkspaceLayerLive),
Layer.provideMerge(ProjectFaviconResolverLayerLive),
- Layer.provideMerge(RepositoryIdentityResolverLive),
- Layer.provideMerge(ServerEnvironmentLive),
+ Layer.provideMerge(RepositoryIdentityResolver.layer),
+ Layer.provideMerge(ServerEnvironment.layer),
Layer.provideMerge(AuthLayerLive),
Layer.provideMerge(ServerSecretStore.layer),
Layer.provideMerge(
diff --git a/apps/server/src/serverRuntimeStartup.test.ts b/apps/server/src/serverRuntimeStartup.test.ts
index 2109f4c5458..e331f0cd4d6 100644
--- a/apps/server/src/serverRuntimeStartup.test.ts
+++ b/apps/server/src/serverRuntimeStartup.test.ts
@@ -57,7 +57,10 @@ it.effect("enqueueCommand fails queued work when readiness fails", () =>
yield* commandGate.failCommandReady(
new ServerRuntimeStartup.ServerRuntimeStartupError({
- stage: "command-readiness",
+ mode: "web",
+ host: "127.0.0.1",
+ port: 3773,
+ cause: new Error("test startup failure"),
}),
);
diff --git a/apps/server/src/serverRuntimeStartup.ts b/apps/server/src/serverRuntimeStartup.ts
index 35ac5a06fc9..cbdf58c4d67 100644
--- a/apps/server/src/serverRuntimeStartup.ts
+++ b/apps/server/src/serverRuntimeStartup.ts
@@ -30,8 +30,8 @@ import * as ProjectionSnapshotQuery from "./orchestration/Services/ProjectionSna
import * as OrchestrationReactor from "./orchestration/Services/OrchestrationReactor.ts";
import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts";
import * as ServerSettings from "./serverSettings.ts";
-import * as ServerEnvironment from "./environment/Services/ServerEnvironment.ts";
import * as AnalyticsService from "./telemetry/AnalyticsService.ts";
+import * as ServerEnvironment from "./environment/ServerEnvironment.ts";
import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts";
import * as ProviderSessionReaper from "./provider/Services/ProviderSessionReaper.ts";
import {
@@ -44,15 +44,14 @@ import {
export class ServerRuntimeStartupError extends Schema.TaggedErrorClass()(
"ServerRuntimeStartupError",
{
- stage: Schema.Literal("command-readiness"),
- cause: Schema.optional(Schema.Defect()),
+ mode: ServerConfig.RuntimeMode,
+ host: Schema.NullOr(Schema.String),
+ port: Schema.Number,
+ cause: Schema.Defect(),
},
) {
override get message(): string {
- switch (this.stage) {
- case "command-readiness":
- return "Server runtime startup failed before command readiness.";
- }
+ return "Server runtime startup failed before command readiness.";
}
}
@@ -289,7 +288,7 @@ const runStartupPhase = (phase: string, effect: Effect.Effect)
Effect.withSpan(`server.startup.${phase}`),
);
-const make = Effect.gen(function* () {
+export const make = Effect.gen(function* () {
const serverConfig = yield* ServerConfig.ServerConfig;
const keybindings = yield* Keybindings.Keybindings;
const orchestrationReactor = yield* OrchestrationReactor.OrchestrationReactor;
@@ -417,7 +416,9 @@ const make = Effect.gen(function* () {
const startupExit = yield* Effect.exit(startup);
if (Exit.isFailure(startupExit)) {
const error = new ServerRuntimeStartupError({
- stage: "command-readiness",
+ mode: serverConfig.mode,
+ host: serverConfig.host ?? null,
+ port: serverConfig.port,
cause: startupExit.cause,
});
yield* Effect.logError("server runtime startup failed", { cause: startupExit.cause });
diff --git a/apps/server/src/workspace/Layers/WorkspaceFileSystem.ts b/apps/server/src/workspace/Layers/WorkspaceFileSystem.ts
deleted file mode 100644
index 61056042bf3..00000000000
--- a/apps/server/src/workspace/Layers/WorkspaceFileSystem.ts
+++ /dev/null
@@ -1,123 +0,0 @@
-// @effect-diagnostics nodeBuiltinImport:off
-import fsPromises from "node:fs/promises";
-
-import * as Effect from "effect/Effect";
-import * as FileSystem from "effect/FileSystem";
-import * as Layer from "effect/Layer";
-import * as Path from "effect/Path";
-
-import {
- WorkspaceFileSystem,
- WorkspaceFileSystemError,
- type WorkspaceFileSystemShape,
-} from "../Services/WorkspaceFileSystem.ts";
-import * as WorkspaceEntries from "../WorkspaceEntries.ts";
-import { WorkspacePaths } from "../Services/WorkspacePaths.ts";
-
-const PROJECT_READ_FILE_MAX_BYTES = 1024 * 1024;
-
-export const makeWorkspaceFileSystem = Effect.gen(function* () {
- const fileSystem = yield* FileSystem.FileSystem;
- const path = yield* Path.Path;
- const workspacePaths = yield* WorkspacePaths;
- const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries;
-
- const readFile: WorkspaceFileSystemShape["readFile"] = Effect.fn("WorkspaceFileSystem.readFile")(
- function* (input) {
- const target = yield* workspacePaths.resolveRelativePathWithinRoot({
- workspaceRoot: input.cwd,
- relativePath: input.relativePath,
- });
-
- const result = yield* Effect.tryPromise({
- try: async () => {
- const [realWorkspaceRoot, realTargetPath] = await Promise.all([
- fsPromises.realpath(input.cwd),
- fsPromises.realpath(target.absolutePath),
- ]);
- const relativeRealPath = path.relative(realWorkspaceRoot, realTargetPath);
- if (
- relativeRealPath.startsWith(`..${path.sep}`) ||
- relativeRealPath === ".." ||
- path.isAbsolute(relativeRealPath)
- ) {
- throw new Error("Workspace file path resolves outside the project root.");
- }
-
- const handle = await fsPromises.open(realTargetPath, "r");
- try {
- const stat = await handle.stat();
- if (!stat.isFile()) {
- throw new Error("Workspace path is not a file.");
- }
- const bytesToRead = Math.min(stat.size, PROJECT_READ_FILE_MAX_BYTES);
- const buffer = Buffer.alloc(bytesToRead);
- const { bytesRead } = await handle.read(buffer, 0, bytesToRead, 0);
- const fileBytes = buffer.subarray(0, bytesRead);
- if (fileBytes.includes(0)) {
- throw new Error("Binary files cannot be previewed as text.");
- }
- const contents = new TextDecoder("utf-8").decode(fileBytes);
- return {
- relativePath: target.relativePath,
- contents,
- byteLength: stat.size,
- truncated: stat.size > PROJECT_READ_FILE_MAX_BYTES,
- };
- } finally {
- await handle.close();
- }
- },
- catch: (cause) =>
- new WorkspaceFileSystemError({
- cwd: input.cwd,
- relativePath: input.relativePath,
- operation: "workspaceFileSystem.readFile",
- detail: cause instanceof Error ? cause.message : String(cause),
- cause,
- }),
- });
-
- return result;
- },
- );
-
- const writeFile: WorkspaceFileSystemShape["writeFile"] = Effect.fn(
- "WorkspaceFileSystem.writeFile",
- )(function* (input) {
- const target = yield* workspacePaths.resolveRelativePathWithinRoot({
- workspaceRoot: input.cwd,
- relativePath: input.relativePath,
- });
-
- yield* fileSystem.makeDirectory(path.dirname(target.absolutePath), { recursive: true }).pipe(
- Effect.mapError(
- (cause) =>
- new WorkspaceFileSystemError({
- cwd: input.cwd,
- relativePath: input.relativePath,
- operation: "workspaceFileSystem.makeDirectory",
- detail: cause.message,
- cause,
- }),
- ),
- );
- yield* fileSystem.writeFileString(target.absolutePath, input.contents).pipe(
- Effect.mapError(
- (cause) =>
- new WorkspaceFileSystemError({
- cwd: input.cwd,
- relativePath: input.relativePath,
- operation: "workspaceFileSystem.writeFile",
- detail: cause.message,
- cause,
- }),
- ),
- );
- yield* workspaceEntries.refresh(input.cwd);
- return { relativePath: target.relativePath };
- });
- return { readFile, writeFile } satisfies WorkspaceFileSystemShape;
-});
-
-export const WorkspaceFileSystemLive = Layer.effect(WorkspaceFileSystem, makeWorkspaceFileSystem);
diff --git a/apps/server/src/workspace/Layers/WorkspacePaths.ts b/apps/server/src/workspace/Layers/WorkspacePaths.ts
deleted file mode 100644
index dfe02e8f67c..00000000000
--- a/apps/server/src/workspace/Layers/WorkspacePaths.ts
+++ /dev/null
@@ -1,107 +0,0 @@
-import * as NodeOS from "node:os";
-import * as Effect from "effect/Effect";
-import * as FileSystem from "effect/FileSystem";
-import * as Layer from "effect/Layer";
-import * as Path from "effect/Path";
-
-import {
- WorkspacePaths,
- WorkspacePathOutsideRootError,
- WorkspaceRootCreateFailedError,
- WorkspaceRootNotDirectoryError,
- WorkspaceRootNotExistsError,
- type WorkspacePathsShape,
-} from "../Services/WorkspacePaths.ts";
-
-function toPosixRelativePath(input: string): string {
- return input.replaceAll("\\", "/");
-}
-
-function expandHomePath(input: string, path: Path.Path): string {
- if (input === "~") {
- return NodeOS.homedir();
- }
- if (input.startsWith("~/") || input.startsWith("~\\")) {
- return path.join(NodeOS.homedir(), input.slice(2));
- }
- return input;
-}
-
-export const makeWorkspacePaths = Effect.gen(function* () {
- const fileSystem = yield* FileSystem.FileSystem;
- const path = yield* Path.Path;
-
- const normalizeWorkspaceRoot: WorkspacePathsShape["normalizeWorkspaceRoot"] = Effect.fn(
- "WorkspacePaths.normalizeWorkspaceRoot",
- )(function* (workspaceRoot, options) {
- const normalizedWorkspaceRoot = path.resolve(expandHomePath(workspaceRoot.trim(), path));
- let workspaceStat = yield* fileSystem
- .stat(normalizedWorkspaceRoot)
- .pipe(Effect.orElseSucceed(() => null));
- if (!workspaceStat && options?.createIfMissing) {
- yield* fileSystem.makeDirectory(normalizedWorkspaceRoot, { recursive: true }).pipe(
- Effect.mapError(
- () =>
- new WorkspaceRootCreateFailedError({
- workspaceRoot,
- normalizedWorkspaceRoot,
- }),
- ),
- );
- workspaceStat = yield* fileSystem
- .stat(normalizedWorkspaceRoot)
- .pipe(Effect.orElseSucceed(() => null));
- }
- if (!workspaceStat) {
- return yield* new WorkspaceRootNotExistsError({
- workspaceRoot,
- normalizedWorkspaceRoot,
- });
- }
- if (workspaceStat.type !== "Directory") {
- return yield* new WorkspaceRootNotDirectoryError({
- workspaceRoot,
- normalizedWorkspaceRoot,
- });
- }
- return normalizedWorkspaceRoot;
- });
-
- const resolveRelativePathWithinRoot: WorkspacePathsShape["resolveRelativePathWithinRoot"] =
- Effect.fn("WorkspacePaths.resolveRelativePathWithinRoot")(function* (input) {
- const normalizedInputPath = input.relativePath.trim();
- if (path.isAbsolute(normalizedInputPath)) {
- return yield* new WorkspacePathOutsideRootError({
- workspaceRoot: input.workspaceRoot,
- relativePath: input.relativePath,
- });
- }
-
- const absolutePath = path.resolve(input.workspaceRoot, normalizedInputPath);
- const relativeToRoot = toPosixRelativePath(path.relative(input.workspaceRoot, absolutePath));
- if (
- relativeToRoot.length === 0 ||
- relativeToRoot === "." ||
- relativeToRoot.startsWith("../") ||
- relativeToRoot === ".." ||
- path.isAbsolute(relativeToRoot)
- ) {
- return yield* new WorkspacePathOutsideRootError({
- workspaceRoot: input.workspaceRoot,
- relativePath: input.relativePath,
- });
- }
-
- return {
- absolutePath,
- relativePath: relativeToRoot,
- };
- });
-
- return {
- normalizeWorkspaceRoot,
- resolveRelativePathWithinRoot,
- } satisfies WorkspacePathsShape;
-});
-
-export const WorkspacePathsLive = Layer.effect(WorkspacePaths, makeWorkspacePaths);
diff --git a/apps/server/src/workspace/Services/WorkspaceFileSystem.ts b/apps/server/src/workspace/Services/WorkspaceFileSystem.ts
deleted file mode 100644
index 5126ec417bf..00000000000
--- a/apps/server/src/workspace/Services/WorkspaceFileSystem.ts
+++ /dev/null
@@ -1,70 +0,0 @@
-/**
- * WorkspaceFileSystem - Effect service contract for workspace file mutations.
- *
- * Owns workspace-root-relative file write operations and their associated
- * safety checks and cache invalidation hooks.
- *
- * @module WorkspaceFileSystem
- */
-import * as Schema from "effect/Schema";
-import * as Context from "effect/Context";
-import type * as Effect from "effect/Effect";
-
-import type {
- ProjectReadFileInput,
- ProjectReadFileResult,
- ProjectWriteFileInput,
- ProjectWriteFileResult,
-} from "@t3tools/contracts";
-import { WorkspacePathOutsideRootError } from "./WorkspacePaths.ts";
-
-export class WorkspaceFileSystemError extends Schema.TaggedErrorClass()(
- "WorkspaceFileSystemError",
- {
- cwd: Schema.String,
- relativePath: Schema.optional(Schema.String),
- operation: Schema.String,
- detail: Schema.String,
- cause: Schema.optional(Schema.Defect()),
- },
-) {
- override get message(): string {
- return this.detail;
- }
-}
-
-/**
- * WorkspaceFileSystemShape - Service API for workspace-relative file operations.
- */
-export interface WorkspaceFileSystemShape {
- /**
- * Read a UTF-8 text file relative to the workspace root.
- */
- readonly readFile: (
- input: ProjectReadFileInput,
- ) => Effect.Effect<
- ProjectReadFileResult,
- WorkspaceFileSystemError | WorkspacePathOutsideRootError
- >;
-
- /**
- * Write a file relative to the workspace root.
- *
- * Creates parent directories as needed and rejects paths that escape the
- * workspace root.
- */
- readonly writeFile: (
- input: ProjectWriteFileInput,
- ) => Effect.Effect<
- ProjectWriteFileResult,
- WorkspaceFileSystemError | WorkspacePathOutsideRootError
- >;
-}
-
-/**
- * WorkspaceFileSystem - Service tag for workspace file operations.
- */
-export class WorkspaceFileSystem extends Context.Service<
- WorkspaceFileSystem,
- WorkspaceFileSystemShape
->()("t3/workspace/Services/WorkspaceFileSystem") {}
diff --git a/apps/server/src/workspace/Services/WorkspacePaths.ts b/apps/server/src/workspace/Services/WorkspacePaths.ts
deleted file mode 100644
index 7c57ca19bd2..00000000000
--- a/apps/server/src/workspace/Services/WorkspacePaths.ts
+++ /dev/null
@@ -1,103 +0,0 @@
-/**
- * WorkspacePaths - Effect service contract for workspace path handling.
- *
- * Owns normalization and validation of workspace roots plus safe resolution of
- * workspace-root-relative paths.
- *
- * @module WorkspacePaths
- */
-import * as Schema from "effect/Schema";
-import * as Context from "effect/Context";
-import type * as Effect from "effect/Effect";
-
-export class WorkspaceRootNotExistsError extends Schema.TaggedErrorClass()(
- "WorkspaceRootNotExistsError",
- {
- workspaceRoot: Schema.String,
- normalizedWorkspaceRoot: Schema.String,
- },
-) {
- override get message(): string {
- return `Workspace root does not exist: ${this.normalizedWorkspaceRoot}`;
- }
-}
-
-export class WorkspaceRootCreateFailedError extends Schema.TaggedErrorClass()(
- "WorkspaceRootCreateFailedError",
- {
- workspaceRoot: Schema.String,
- normalizedWorkspaceRoot: Schema.String,
- },
-) {
- override get message(): string {
- return `Failed to create workspace root: ${this.normalizedWorkspaceRoot}`;
- }
-}
-
-export class WorkspaceRootNotDirectoryError extends Schema.TaggedErrorClass()(
- "WorkspaceRootNotDirectoryError",
- {
- workspaceRoot: Schema.String,
- normalizedWorkspaceRoot: Schema.String,
- },
-) {
- override get message(): string {
- return `Workspace root is not a directory: ${this.normalizedWorkspaceRoot}`;
- }
-}
-
-export class WorkspacePathOutsideRootError extends Schema.TaggedErrorClass()(
- "WorkspacePathOutsideRootError",
- {
- workspaceRoot: Schema.String,
- relativePath: Schema.String,
- },
-) {
- override get message(): string {
- return `Workspace file path must be relative to the project root: ${this.relativePath}`;
- }
-}
-
-export const WorkspacePathsError = Schema.Union([
- WorkspaceRootNotExistsError,
- WorkspaceRootCreateFailedError,
- WorkspaceRootNotDirectoryError,
- WorkspacePathOutsideRootError,
-]);
-export type WorkspacePathsError = typeof WorkspacePathsError.Type;
-
-/**
- * WorkspacePathsShape - Service API for workspace path normalization and guards.
- */
-export interface WorkspacePathsShape {
- /**
- * Normalize a user-provided workspace root and verify it exists as a directory.
- */
- readonly normalizeWorkspaceRoot: (
- workspaceRoot: string,
- options?: { readonly createIfMissing?: boolean },
- ) => Effect.Effect<
- string,
- WorkspaceRootNotExistsError | WorkspaceRootCreateFailedError | WorkspaceRootNotDirectoryError
- >;
-
- /**
- * Resolve a relative path within a validated workspace root.
- *
- * Rejects absolute paths and traversal attempts outside the workspace root.
- */
- readonly resolveRelativePathWithinRoot: (input: {
- workspaceRoot: string;
- relativePath: string;
- }) => Effect.Effect<
- { absolutePath: string; relativePath: string },
- WorkspacePathOutsideRootError
- >;
-}
-
-/**
- * WorkspacePaths - Service tag for workspace path normalization and resolution.
- */
-export class WorkspacePaths extends Context.Service()(
- "t3/workspace/Services/WorkspacePaths",
-) {}
diff --git a/apps/server/src/workspace/WorkspaceEntries.test.ts b/apps/server/src/workspace/WorkspaceEntries.test.ts
index f8a518d8b33..7d6005f030d 100644
--- a/apps/server/src/workspace/WorkspaceEntries.test.ts
+++ b/apps/server/src/workspace/WorkspaceEntries.test.ts
@@ -9,18 +9,18 @@ import * as Layer from "effect/Layer";
import * as Path from "effect/Path";
import * as PlatformError from "effect/PlatformError";
-import { ServerConfig } from "../config.ts";
+import * as ServerConfig from "../config.ts";
import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
import * as VcsProcess from "../vcs/VcsProcess.ts";
import * as WorkspaceEntries from "./WorkspaceEntries.ts";
-import { WorkspacePathsLive } from "./Layers/WorkspacePaths.ts";
+import * as WorkspacePaths from "./WorkspacePaths.ts";
const TestLayer = Layer.empty.pipe(
- Layer.provideMerge(WorkspaceEntries.layer.pipe(Layer.provide(WorkspacePathsLive))),
- Layer.provideMerge(WorkspacePathsLive),
+ Layer.provideMerge(WorkspaceEntries.layer.pipe(Layer.provide(WorkspacePaths.layer))),
+ Layer.provideMerge(WorkspacePaths.layer),
Layer.provideMerge(VcsProcess.layer),
Layer.provide(
- ServerConfig.layerTest(process.cwd(), {
+ ServerConfig.ServerConfig.layerTest(process.cwd(), {
prefix: "t3-workspace-entries-test-",
}),
),
@@ -363,7 +363,10 @@ it.layer(TestLayer, { excludeTestServices: true })("WorkspaceEntries", (it) => {
})
.pipe(Effect.flip);
- expect(error.detail).toBe("Relative filesystem browse paths require a current project.");
+ expect(error._tag).toBe("WorkspaceEntriesCurrentProjectRequiredError");
+ expect(error.message).toBe(
+ "A current project is required to browse relative workspace path './src'.",
+ );
}),
);
diff --git a/apps/server/src/workspace/WorkspaceEntries.ts b/apps/server/src/workspace/WorkspaceEntries.ts
index bf9a51c74db..aafd6ffd75a 100644
--- a/apps/server/src/workspace/WorkspaceEntries.ts
+++ b/apps/server/src/workspace/WorkspaceEntries.ts
@@ -1,6 +1,6 @@
// @effect-diagnostics nodeBuiltinImport:off
-import * as NodeFSP from "node:fs/promises";
-import * as NodeOS from "node:os";
+import { readdir } from "node:fs/promises";
+import { homedir } from "node:os";
import * as Context from "effect/Context";
import * as Effect from "effect/Effect";
@@ -20,29 +20,72 @@ import type {
import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
import { isExplicitRelativePath, isWindowsAbsolutePath } from "@t3tools/shared/path";
-import * as WorkspacePaths from "./Services/WorkspacePaths.ts";
+import * as WorkspacePaths from "./WorkspacePaths.ts";
import * as WorkspaceSearchIndex from "./WorkspaceSearchIndex.ts";
export class WorkspaceEntriesError extends Schema.TaggedErrorClass()(
"WorkspaceEntriesError",
{
cwd: Schema.String,
- operation: Schema.String,
- detail: Schema.String,
- cause: Schema.optional(Schema.Defect()),
+ operation: Schema.Literals([
+ "workspaceEntries.normalizeWorkspaceRoot",
+ "workspaceEntries.search",
+ "workspaceEntries.list",
+ ]),
+ cause: Schema.Defect(),
},
-) {}
+) {
+ override get message(): string {
+ return `Workspace entries operation '${this.operation}' failed for '${this.cwd}'.`;
+ }
+}
-export class WorkspaceEntriesBrowseError extends Schema.TaggedErrorClass()(
- "WorkspaceEntriesBrowseError",
+export class WorkspaceEntriesWindowsPathUnsupportedError extends Schema.TaggedErrorClass()(
+ "WorkspaceEntriesWindowsPathUnsupportedError",
{
cwd: Schema.optional(Schema.String),
partialPath: Schema.String,
- operation: Schema.String,
- detail: Schema.String,
- cause: Schema.optional(Schema.Defect()),
+ platform: Schema.String,
},
-) {}
+) {
+ override get message(): string {
+ const cwd = this.cwd ? ` from '${this.cwd}'` : "";
+ return `Windows-style workspace path '${this.partialPath}' is not supported on '${this.platform}'${cwd}.`;
+ }
+}
+
+export class WorkspaceEntriesCurrentProjectRequiredError extends Schema.TaggedErrorClass()(
+ "WorkspaceEntriesCurrentProjectRequiredError",
+ {
+ partialPath: Schema.String,
+ },
+) {
+ override get message(): string {
+ return `A current project is required to browse relative workspace path '${this.partialPath}'.`;
+ }
+}
+
+export class WorkspaceEntriesReadDirectoryError extends Schema.TaggedErrorClass()(
+ "WorkspaceEntriesReadDirectoryError",
+ {
+ cwd: Schema.optional(Schema.String),
+ partialPath: Schema.String,
+ parentPath: Schema.String,
+ cause: Schema.Defect(),
+ },
+) {
+ override get message(): string {
+ const cwd = this.cwd ? ` from '${this.cwd}'` : "";
+ return `Failed to read workspace directory '${this.parentPath}' while browsing '${this.partialPath}'${cwd}.`;
+ }
+}
+
+export const WorkspaceEntriesBrowseError = Schema.Union([
+ WorkspaceEntriesWindowsPathUnsupportedError,
+ WorkspaceEntriesCurrentProjectRequiredError,
+ WorkspaceEntriesReadDirectoryError,
+]);
+export type WorkspaceEntriesBrowseError = typeof WorkspaceEntriesBrowseError.Type;
export class WorkspaceEntries extends Context.Service<
WorkspaceEntries,
@@ -62,46 +105,40 @@ export class WorkspaceEntries extends Context.Service<
function expandHomePath(input: string, path: Path.Path): string {
if (input === "~") {
- return NodeOS.homedir();
+ return homedir();
}
if (input.startsWith("~/") || input.startsWith("~\\")) {
- return path.join(NodeOS.homedir(), input.slice(2));
+ return path.join(homedir(), input.slice(2));
}
return input;
}
-const resolveBrowseTarget = (
+const resolveBrowseTarget = Effect.fn("WorkspaceEntries.resolveBrowseTarget")(function* (
input: FilesystemBrowseInput,
path: Path.Path,
-): Effect.Effect =>
- Effect.gen(function* () {
- const platform = yield* HostProcessPlatform;
- if (platform !== "win32" && isWindowsAbsolutePath(input.partialPath)) {
- return yield* new WorkspaceEntriesBrowseError({
- cwd: input.cwd,
- partialPath: input.partialPath,
- operation: "workspaceEntries.resolveBrowseTarget",
- detail: "Windows-style paths are only supported on Windows.",
- });
- }
-
- if (!isExplicitRelativePath(input.partialPath)) {
- return path.resolve(expandHomePath(input.partialPath, path));
- }
-
- if (!input.cwd) {
- return yield* new WorkspaceEntriesBrowseError({
- cwd: input.cwd,
- partialPath: input.partialPath,
- operation: "workspaceEntries.resolveBrowseTarget",
- detail: "Relative filesystem browse paths require a current project.",
- });
- }
-
- return path.resolve(expandHomePath(input.cwd, path), input.partialPath);
- });
+): Effect.fn.Return {
+ const platform = yield* HostProcessPlatform;
+ if (platform !== "win32" && isWindowsAbsolutePath(input.partialPath)) {
+ return yield* new WorkspaceEntriesWindowsPathUnsupportedError({
+ cwd: input.cwd,
+ partialPath: input.partialPath,
+ platform,
+ });
+ }
+
+ if (!isExplicitRelativePath(input.partialPath)) {
+ return path.resolve(expandHomePath(input.partialPath, path));
+ }
+
+ if (!input.cwd) {
+ return yield* new WorkspaceEntriesCurrentProjectRequiredError({
+ partialPath: input.partialPath,
+ });
+ }
+ return path.resolve(expandHomePath(input.cwd, path), input.partialPath);
+});
-const make = Effect.gen(function* () {
+export const make = Effect.gen(function* () {
const path = yield* Path.Path;
const workspacePaths = yield* WorkspacePaths.WorkspacePaths;
const workspaceSearchIndexes = yield* WorkspaceSearchIndex.WorkspaceSearchIndexMap;
@@ -115,7 +152,6 @@ const make = Effect.gen(function* () {
new WorkspaceEntriesError({
cwd,
operation: "workspaceEntries.normalizeWorkspaceRoot",
- detail: cause.message,
cause,
}),
),
@@ -156,13 +192,12 @@ const make = Effect.gen(function* () {
const prefix = endsWithSeparator ? "" : path.basename(resolvedInputPath);
const dirents = yield* Effect.tryPromise({
- try: () => NodeFSP.readdir(parentPath, { withFileTypes: true }),
+ try: () => readdir(parentPath, { withFileTypes: true }),
catch: (cause) =>
- new WorkspaceEntriesBrowseError({
+ new WorkspaceEntriesReadDirectoryError({
cwd: input.cwd,
partialPath: input.partialPath,
- operation: "workspaceEntries.browse.readDirectory",
- detail: `Unable to browse '${parentPath}': ${cause instanceof Error ? cause.message : String(cause)}`,
+ parentPath,
cause,
}),
}).pipe(
@@ -215,7 +250,6 @@ const make = Effect.gen(function* () {
new WorkspaceEntriesError({
cwd: input.cwd,
operation: "workspaceEntries.search",
- detail: cause.message,
cause,
}),
),
@@ -236,7 +270,6 @@ const make = Effect.gen(function* () {
new WorkspaceEntriesError({
cwd: input.cwd,
operation: "workspaceEntries.list",
- detail: cause.message,
cause,
}),
),
diff --git a/apps/server/src/workspace/Layers/WorkspaceFileSystem.test.ts b/apps/server/src/workspace/WorkspaceFileSystem.test.ts
similarity index 79%
rename from apps/server/src/workspace/Layers/WorkspaceFileSystem.test.ts
rename to apps/server/src/workspace/WorkspaceFileSystem.test.ts
index 5a4ec54686e..aa2dabb3337 100644
--- a/apps/server/src/workspace/Layers/WorkspaceFileSystem.test.ts
+++ b/apps/server/src/workspace/WorkspaceFileSystem.test.ts
@@ -5,26 +5,25 @@ import * as FileSystem from "effect/FileSystem";
import * as Layer from "effect/Layer";
import * as Path from "effect/Path";
-import { ServerConfig } from "../../config.ts";
-import * as VcsDriverRegistry from "../../vcs/VcsDriverRegistry.ts";
-import * as VcsProcess from "../../vcs/VcsProcess.ts";
-import * as WorkspaceEntries from "../WorkspaceEntries.ts";
-import { WorkspaceFileSystem } from "../Services/WorkspaceFileSystem.ts";
-import { WorkspaceFileSystemLive } from "./WorkspaceFileSystem.ts";
-import { WorkspacePathsLive } from "./WorkspacePaths.ts";
-
-const ProjectLayer = WorkspaceFileSystemLive.pipe(
- Layer.provide(WorkspacePathsLive),
- Layer.provide(WorkspaceEntries.layer.pipe(Layer.provide(WorkspacePathsLive))),
+import * as ServerConfig from "../config.ts";
+import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts";
+import * as VcsProcess from "../vcs/VcsProcess.ts";
+import * as WorkspaceEntries from "./WorkspaceEntries.ts";
+import * as WorkspaceFileSystem from "./WorkspaceFileSystem.ts";
+import * as WorkspacePaths from "./WorkspacePaths.ts";
+
+const ProjectLayer = WorkspaceFileSystem.layer.pipe(
+ Layer.provide(WorkspacePaths.layer),
+ Layer.provide(WorkspaceEntries.layer.pipe(Layer.provide(WorkspacePaths.layer))),
);
const TestLayer = Layer.empty.pipe(
Layer.provideMerge(ProjectLayer),
- Layer.provideMerge(WorkspaceEntries.layer.pipe(Layer.provide(WorkspacePathsLive))),
- Layer.provideMerge(WorkspacePathsLive),
+ Layer.provideMerge(WorkspaceEntries.layer.pipe(Layer.provide(WorkspacePaths.layer))),
+ Layer.provideMerge(WorkspacePaths.layer),
Layer.provideMerge(VcsDriverRegistry.layer.pipe(Layer.provide(VcsProcess.layer))),
Layer.provide(
- ServerConfig.layerTest(process.cwd(), {
+ ServerConfig.ServerConfig.layerTest(process.cwd(), {
prefix: "t3-workspace-files-test-",
}),
),
@@ -56,7 +55,7 @@ it.layer(TestLayer, { excludeTestServices: true })("WorkspaceFileSystemLive", (i
describe("readFile", () => {
it.effect("reads UTF-8 files relative to the workspace root", () =>
Effect.gen(function* () {
- const workspaceFileSystem = yield* WorkspaceFileSystem;
+ const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem;
const cwd = yield* makeTempDir;
yield* writeTextFile(cwd, "src/index.ts", "export const answer = 42;\n");
@@ -76,7 +75,7 @@ it.layer(TestLayer, { excludeTestServices: true })("WorkspaceFileSystemLive", (i
it.effect("rejects reads outside the workspace root", () =>
Effect.gen(function* () {
- const workspaceFileSystem = yield* WorkspaceFileSystem;
+ const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem;
const cwd = yield* makeTempDir;
const error = yield* workspaceFileSystem
@@ -91,7 +90,7 @@ it.layer(TestLayer, { excludeTestServices: true })("WorkspaceFileSystemLive", (i
it.effect("rejects symlinks that resolve outside the workspace root", () =>
Effect.gen(function* () {
- const workspaceFileSystem = yield* WorkspaceFileSystem;
+ const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem;
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const cwd = yield* makeTempDir;
@@ -106,7 +105,13 @@ it.layer(TestLayer, { excludeTestServices: true })("WorkspaceFileSystemLive", (i
.readFile({ cwd, relativePath: "linked-secret.txt" })
.pipe(Effect.flip);
- expect(error.message).toContain("resolves outside the project root");
+ expect(error.message).toBe(
+ `Workspace file operation 'workspaceFileSystem.readFile' failed for 'linked-secret.txt' in '${cwd}'.`,
+ );
+ expect(error.cause).toBeInstanceOf(Error);
+ expect((error.cause as Error).message).toBe(
+ "Workspace file path resolves outside the project root.",
+ );
}),
);
});
@@ -114,7 +119,7 @@ it.layer(TestLayer, { excludeTestServices: true })("WorkspaceFileSystemLive", (i
describe("writeFile", () => {
it.effect("writes files relative to the workspace root", () =>
Effect.gen(function* () {
- const workspaceFileSystem = yield* WorkspaceFileSystem;
+ const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem;
const cwd = yield* makeTempDir;
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
@@ -135,7 +140,7 @@ it.layer(TestLayer, { excludeTestServices: true })("WorkspaceFileSystemLive", (i
it.effect("invalidates workspace entry search cache after writes", () =>
Effect.gen(function* () {
const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries;
- const workspaceFileSystem = yield* WorkspaceFileSystem;
+ const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem;
const cwd = yield* makeTempDir;
yield* writeTextFile(cwd, "src/existing.ts", "export {};\n");
@@ -160,7 +165,7 @@ it.layer(TestLayer, { excludeTestServices: true })("WorkspaceFileSystemLive", (i
it.effect("rejects writes outside the workspace root", () =>
Effect.gen(function* () {
- const workspaceFileSystem = yield* WorkspaceFileSystem;
+ const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem;
const cwd = yield* makeTempDir;
const path = yield* Path.Path;
const fileSystem = yield* FileSystem.FileSystem;
diff --git a/apps/server/src/workspace/WorkspaceFileSystem.ts b/apps/server/src/workspace/WorkspaceFileSystem.ts
new file mode 100644
index 00000000000..48e02c89cae
--- /dev/null
+++ b/apps/server/src/workspace/WorkspaceFileSystem.ts
@@ -0,0 +1,175 @@
+// @effect-diagnostics nodeBuiltinImport:off
+/**
+ * WorkspaceFileSystem - Effect service contract for workspace file mutations.
+ *
+ * Owns workspace-root-relative file read/write operations and their associated
+ * safety checks and cache invalidation hooks.
+ *
+ * @module WorkspaceFileSystem
+ */
+import { open, realpath } from "node:fs/promises";
+
+import type {
+ ProjectReadFileInput,
+ ProjectReadFileResult,
+ ProjectWriteFileInput,
+ ProjectWriteFileResult,
+} from "@t3tools/contracts";
+import * as Context from "effect/Context";
+import * as Effect from "effect/Effect";
+import * as FileSystem from "effect/FileSystem";
+import * as Layer from "effect/Layer";
+import * as Path from "effect/Path";
+import * as Schema from "effect/Schema";
+
+import * as WorkspaceEntries from "./WorkspaceEntries.ts";
+import * as WorkspacePaths from "./WorkspacePaths.ts";
+
+const PROJECT_READ_FILE_MAX_BYTES = 1024 * 1024;
+
+export class WorkspaceFileSystemError extends Schema.TaggedErrorClass()(
+ "WorkspaceFileSystemError",
+ {
+ cwd: Schema.String,
+ relativePath: Schema.optional(Schema.String),
+ operation: Schema.Literals([
+ "workspaceFileSystem.readFile",
+ "workspaceFileSystem.makeDirectory",
+ "workspaceFileSystem.writeFile",
+ ]),
+ cause: Schema.Defect(),
+ },
+) {
+ override get message(): string {
+ const target = this.relativePath ? `'${this.relativePath}' in '${this.cwd}'` : `'${this.cwd}'`;
+ return `Workspace file operation '${this.operation}' failed for ${target}.`;
+ }
+}
+
+/** Service tag for workspace file operations. */
+export class WorkspaceFileSystem extends Context.Service<
+ WorkspaceFileSystem,
+ {
+ /** Read a UTF-8 text file relative to the workspace root. */
+ readonly readFile: (
+ input: ProjectReadFileInput,
+ ) => Effect.Effect<
+ ProjectReadFileResult,
+ WorkspaceFileSystemError | WorkspacePaths.WorkspacePathOutsideRootError
+ >;
+ /**
+ * Write a file relative to the workspace root.
+ *
+ * Creates parent directories as needed and rejects paths that escape the
+ * workspace root.
+ */
+ readonly writeFile: (
+ input: ProjectWriteFileInput,
+ ) => Effect.Effect<
+ ProjectWriteFileResult,
+ WorkspaceFileSystemError | WorkspacePaths.WorkspacePathOutsideRootError
+ >;
+ }
+>()("t3/workspace/WorkspaceFileSystem") {}
+
+export const make = Effect.gen(function* () {
+ const fileSystem = yield* FileSystem.FileSystem;
+ const path = yield* Path.Path;
+ const workspacePaths = yield* WorkspacePaths.WorkspacePaths;
+ const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries;
+
+ const readFile: WorkspaceFileSystem["Service"]["readFile"] = Effect.fn(
+ "WorkspaceFileSystem.readFile",
+ )(function* (input) {
+ const target = yield* workspacePaths.resolveRelativePathWithinRoot({
+ workspaceRoot: input.cwd,
+ relativePath: input.relativePath,
+ });
+
+ return yield* Effect.tryPromise({
+ try: async () => {
+ const [realWorkspaceRoot, realTargetPath] = await Promise.all([
+ realpath(input.cwd),
+ realpath(target.absolutePath),
+ ]);
+ const relativeRealPath = path.relative(realWorkspaceRoot, realTargetPath);
+ if (
+ relativeRealPath.startsWith(`..${path.sep}`) ||
+ relativeRealPath === ".." ||
+ path.isAbsolute(relativeRealPath)
+ ) {
+ throw new Error("Workspace file path resolves outside the project root.");
+ }
+
+ const handle = await open(realTargetPath, "r");
+ try {
+ const stat = await handle.stat();
+ if (!stat.isFile()) {
+ throw new Error("Workspace path is not a file.");
+ }
+ const bytesToRead = Math.min(stat.size, PROJECT_READ_FILE_MAX_BYTES);
+ const buffer = Buffer.alloc(bytesToRead);
+ const { bytesRead } = await handle.read(buffer, 0, bytesToRead, 0);
+ const fileBytes = buffer.subarray(0, bytesRead);
+ if (fileBytes.includes(0)) {
+ throw new Error("Binary files cannot be previewed as text.");
+ }
+ const contents = new TextDecoder("utf-8").decode(fileBytes);
+ return {
+ relativePath: target.relativePath,
+ contents,
+ byteLength: stat.size,
+ truncated: stat.size > PROJECT_READ_FILE_MAX_BYTES,
+ };
+ } finally {
+ await handle.close();
+ }
+ },
+ catch: (cause) =>
+ new WorkspaceFileSystemError({
+ cwd: input.cwd,
+ relativePath: input.relativePath,
+ operation: "workspaceFileSystem.readFile",
+ cause,
+ }),
+ });
+ });
+
+ const writeFile: WorkspaceFileSystem["Service"]["writeFile"] = Effect.fn(
+ "WorkspaceFileSystem.writeFile",
+ )(function* (input) {
+ const target = yield* workspacePaths.resolveRelativePathWithinRoot({
+ workspaceRoot: input.cwd,
+ relativePath: input.relativePath,
+ });
+
+ yield* fileSystem.makeDirectory(path.dirname(target.absolutePath), { recursive: true }).pipe(
+ Effect.mapError(
+ (cause) =>
+ new WorkspaceFileSystemError({
+ cwd: input.cwd,
+ relativePath: input.relativePath,
+ operation: "workspaceFileSystem.makeDirectory",
+ cause,
+ }),
+ ),
+ );
+ yield* fileSystem.writeFileString(target.absolutePath, input.contents).pipe(
+ Effect.mapError(
+ (cause) =>
+ new WorkspaceFileSystemError({
+ cwd: input.cwd,
+ relativePath: input.relativePath,
+ operation: "workspaceFileSystem.writeFile",
+ cause,
+ }),
+ ),
+ );
+ yield* workspaceEntries.refresh(input.cwd);
+ return { relativePath: target.relativePath };
+ });
+
+ return WorkspaceFileSystem.of({ readFile, writeFile });
+});
+
+export const layer = Layer.effect(WorkspaceFileSystem, make);
diff --git a/apps/server/src/workspace/Layers/WorkspacePaths.test.ts b/apps/server/src/workspace/WorkspacePaths.test.ts
similarity index 88%
rename from apps/server/src/workspace/Layers/WorkspacePaths.test.ts
rename to apps/server/src/workspace/WorkspacePaths.test.ts
index 0a9252a7def..ecce54b67d6 100644
--- a/apps/server/src/workspace/Layers/WorkspacePaths.test.ts
+++ b/apps/server/src/workspace/WorkspacePaths.test.ts
@@ -5,11 +5,10 @@ import * as FileSystem from "effect/FileSystem";
import * as Layer from "effect/Layer";
import * as Path from "effect/Path";
-import { WorkspacePaths } from "../Services/WorkspacePaths.ts";
-import { WorkspacePathsLive } from "./WorkspacePaths.ts";
+import * as WorkspacePaths from "./WorkspacePaths.ts";
const TestLayer = Layer.empty.pipe(
- Layer.provideMerge(WorkspacePathsLive),
+ Layer.provideMerge(WorkspacePaths.layer),
Layer.provideMerge(NodeServices.layer),
);
@@ -38,7 +37,7 @@ it.layer(TestLayer)("WorkspacePathsLive", (it) => {
describe("normalizeWorkspaceRoot", () => {
it.effect("resolves an existing directory", () =>
Effect.gen(function* () {
- const workspacePaths = yield* WorkspacePaths;
+ const workspacePaths = yield* WorkspacePaths.WorkspacePaths;
const cwd = yield* makeTempDir();
const resolved = yield* workspacePaths.normalizeWorkspaceRoot(cwd);
@@ -49,7 +48,7 @@ it.layer(TestLayer)("WorkspacePathsLive", (it) => {
it.effect("rejects missing directories", () =>
Effect.gen(function* () {
- const workspacePaths = yield* WorkspacePaths;
+ const workspacePaths = yield* WorkspacePaths.WorkspacePaths;
const cwd = yield* makeTempDir();
const path = yield* Path.Path;
@@ -63,7 +62,7 @@ it.layer(TestLayer)("WorkspacePathsLive", (it) => {
it.effect("creates missing directories when createIfMissing is enabled", () =>
Effect.gen(function* () {
- const workspacePaths = yield* WorkspacePaths;
+ const workspacePaths = yield* WorkspacePaths.WorkspacePaths;
const fileSystem = yield* FileSystem.FileSystem;
const cwd = yield* makeTempDir();
const path = yield* Path.Path;
@@ -81,7 +80,7 @@ it.layer(TestLayer)("WorkspacePathsLive", (it) => {
it.effect("rejects file paths", () =>
Effect.gen(function* () {
- const workspacePaths = yield* WorkspacePaths;
+ const workspacePaths = yield* WorkspacePaths.WorkspacePaths;
const cwd = yield* makeTempDir();
const path = yield* Path.Path;
const filePath = path.join(cwd, "README.md");
@@ -97,7 +96,7 @@ it.layer(TestLayer)("WorkspacePathsLive", (it) => {
describe("resolveRelativePathWithinRoot", () => {
it.effect("resolves relative paths inside the workspace root", () =>
Effect.gen(function* () {
- const workspacePaths = yield* WorkspacePaths;
+ const workspacePaths = yield* WorkspacePaths.WorkspacePaths;
const cwd = yield* makeTempDir();
const path = yield* Path.Path;
@@ -115,7 +114,7 @@ it.layer(TestLayer)("WorkspacePathsLive", (it) => {
it.effect("rejects paths that escape the workspace root", () =>
Effect.gen(function* () {
- const workspacePaths = yield* WorkspacePaths;
+ const workspacePaths = yield* WorkspacePaths.WorkspacePaths;
const cwd = yield* makeTempDir();
const error = yield* workspacePaths
diff --git a/apps/server/src/workspace/WorkspacePaths.ts b/apps/server/src/workspace/WorkspacePaths.ts
new file mode 100644
index 00000000000..8b6b685524b
--- /dev/null
+++ b/apps/server/src/workspace/WorkspacePaths.ts
@@ -0,0 +1,191 @@
+/**
+ * WorkspacePaths - Effect service contract for workspace path handling.
+ *
+ * Owns normalization and validation of workspace roots plus safe resolution of
+ * workspace-root-relative paths.
+ *
+ * @module WorkspacePaths
+ */
+import { homedir } from "node:os";
+
+import * as Context from "effect/Context";
+import * as Effect from "effect/Effect";
+import * as FileSystem from "effect/FileSystem";
+import * as Layer from "effect/Layer";
+import * as Path from "effect/Path";
+import * as Schema from "effect/Schema";
+
+export class WorkspaceRootNotExistsError extends Schema.TaggedErrorClass()(
+ "WorkspaceRootNotExistsError",
+ {
+ workspaceRoot: Schema.String,
+ normalizedWorkspaceRoot: Schema.String,
+ },
+) {
+ override get message(): string {
+ return `Workspace root does not exist: ${this.normalizedWorkspaceRoot}`;
+ }
+}
+
+export class WorkspaceRootCreateFailedError extends Schema.TaggedErrorClass()(
+ "WorkspaceRootCreateFailedError",
+ {
+ workspaceRoot: Schema.String,
+ normalizedWorkspaceRoot: Schema.String,
+ cause: Schema.Defect(),
+ },
+) {
+ override get message(): string {
+ return `Failed to create workspace root: ${this.normalizedWorkspaceRoot}`;
+ }
+}
+
+export class WorkspaceRootNotDirectoryError extends Schema.TaggedErrorClass()(
+ "WorkspaceRootNotDirectoryError",
+ {
+ workspaceRoot: Schema.String,
+ normalizedWorkspaceRoot: Schema.String,
+ },
+) {
+ override get message(): string {
+ return `Workspace root is not a directory: ${this.normalizedWorkspaceRoot}`;
+ }
+}
+
+export class WorkspacePathOutsideRootError extends Schema.TaggedErrorClass()(
+ "WorkspacePathOutsideRootError",
+ {
+ workspaceRoot: Schema.String,
+ relativePath: Schema.String,
+ },
+) {
+ override get message(): string {
+ return `Workspace file path must be relative to the project root: ${this.relativePath}`;
+ }
+}
+
+export const WorkspacePathsError = Schema.Union([
+ WorkspaceRootNotExistsError,
+ WorkspaceRootCreateFailedError,
+ WorkspaceRootNotDirectoryError,
+ WorkspacePathOutsideRootError,
+]);
+export type WorkspacePathsError = typeof WorkspacePathsError.Type;
+
+/** Service tag for workspace path normalization and resolution. */
+export class WorkspacePaths extends Context.Service<
+ WorkspacePaths,
+ {
+ /** Normalize a user-provided workspace root and verify it exists as a directory. */
+ readonly normalizeWorkspaceRoot: (
+ workspaceRoot: string,
+ options?: { readonly createIfMissing?: boolean },
+ ) => Effect.Effect<
+ string,
+ WorkspaceRootNotExistsError | WorkspaceRootCreateFailedError | WorkspaceRootNotDirectoryError
+ >;
+ /**
+ * Resolve a relative path within a validated workspace root.
+ *
+ * Rejects absolute paths and traversal attempts outside the workspace root.
+ */
+ readonly resolveRelativePathWithinRoot: (input: {
+ workspaceRoot: string;
+ relativePath: string;
+ }) => Effect.Effect<
+ { absolutePath: string; relativePath: string },
+ WorkspacePathOutsideRootError
+ >;
+ }
+>()("t3/workspace/WorkspacePaths") {}
+
+function toPosixRelativePath(input: string): string {
+ return input.replaceAll("\\", "/");
+}
+
+function expandHomePath(input: string, path: Path.Path): string {
+ if (input === "~") {
+ return homedir();
+ }
+ if (input.startsWith("~/") || input.startsWith("~\\")) {
+ return path.join(homedir(), input.slice(2));
+ }
+ return input;
+}
+
+export const make = Effect.gen(function* () {
+ const fileSystem = yield* FileSystem.FileSystem;
+ const path = yield* Path.Path;
+
+ const normalizeWorkspaceRoot: WorkspacePaths["Service"]["normalizeWorkspaceRoot"] = Effect.fn(
+ "WorkspacePaths.normalizeWorkspaceRoot",
+ )(function* (workspaceRoot, options) {
+ const normalizedWorkspaceRoot = path.resolve(expandHomePath(workspaceRoot.trim(), path));
+ let workspaceStat = yield* fileSystem
+ .stat(normalizedWorkspaceRoot)
+ .pipe(Effect.orElseSucceed(() => null));
+ if (!workspaceStat && options?.createIfMissing) {
+ yield* fileSystem.makeDirectory(normalizedWorkspaceRoot, { recursive: true }).pipe(
+ Effect.mapError(
+ (cause) =>
+ new WorkspaceRootCreateFailedError({
+ workspaceRoot,
+ normalizedWorkspaceRoot,
+ cause,
+ }),
+ ),
+ );
+ workspaceStat = yield* fileSystem
+ .stat(normalizedWorkspaceRoot)
+ .pipe(Effect.orElseSucceed(() => null));
+ }
+ if (!workspaceStat) {
+ return yield* new WorkspaceRootNotExistsError({
+ workspaceRoot,
+ normalizedWorkspaceRoot,
+ });
+ }
+ if (workspaceStat.type !== "Directory") {
+ return yield* new WorkspaceRootNotDirectoryError({
+ workspaceRoot,
+ normalizedWorkspaceRoot,
+ });
+ }
+ return normalizedWorkspaceRoot;
+ });
+
+ const resolveRelativePathWithinRoot: WorkspacePaths["Service"]["resolveRelativePathWithinRoot"] =
+ Effect.fn("WorkspacePaths.resolveRelativePathWithinRoot")(function* (input) {
+ const normalizedInputPath = input.relativePath.trim();
+ if (path.isAbsolute(normalizedInputPath)) {
+ return yield* new WorkspacePathOutsideRootError({
+ workspaceRoot: input.workspaceRoot,
+ relativePath: input.relativePath,
+ });
+ }
+
+ const absolutePath = path.resolve(input.workspaceRoot, normalizedInputPath);
+ const relativeToRoot = toPosixRelativePath(path.relative(input.workspaceRoot, absolutePath));
+ if (
+ relativeToRoot.length === 0 ||
+ relativeToRoot === "." ||
+ relativeToRoot.startsWith("../") ||
+ relativeToRoot === ".." ||
+ path.isAbsolute(relativeToRoot)
+ ) {
+ return yield* new WorkspacePathOutsideRootError({
+ workspaceRoot: input.workspaceRoot,
+ relativePath: input.relativePath,
+ });
+ }
+
+ return {
+ absolutePath,
+ relativePath: relativeToRoot,
+ };
+ });
+
+ return WorkspacePaths.of({ normalizeWorkspaceRoot, resolveRelativePathWithinRoot });
+});
+
+export const layer = Layer.effect(WorkspacePaths, make);
diff --git a/apps/server/src/workspace/WorkspaceSearchIndex.ts b/apps/server/src/workspace/WorkspaceSearchIndex.ts
index 4bee3cbc089..fcacf3caf13 100644
--- a/apps/server/src/workspace/WorkspaceSearchIndex.ts
+++ b/apps/server/src/workspace/WorkspaceSearchIndex.ts
@@ -182,64 +182,69 @@ const waitForScan = Effect.fn("WorkspaceSearchIndex.waitForScan")(function* (
);
});
-const makeWorkspaceSearchIndex = (cwd: string) =>
- Effect.acquireRelease(createFinder(cwd), (finder) => Effect.sync(() => finder.destroy())).pipe(
- Effect.tap((finder) => waitForScan(cwd, finder)),
- Effect.map((finder) => {
- const runMixedSearch = Effect.fn("WorkspaceSearchIndex.runMixedSearch")(function* (
- query: string,
- pageSize: number,
- ) {
- const result = yield* Effect.sync(() => finder.mixedSearch(query, { pageSize }));
- if (!result.ok) {
- return yield* new WorkspaceSearchIndexSearchFailed({ cwd, reason: result.error });
- }
- return result.value;
- });
-
- const refresh: WorkspaceSearchIndex["Service"]["refresh"] = Effect.fn(
- "WorkspaceSearchIndex.refresh",
- )(function* () {
- const result = yield* Effect.sync(() => finder.scanFiles());
- if (!result.ok) {
- return yield* new WorkspaceSearchIndexRefreshFailed({ cwd, reason: result.error });
- }
- yield* waitForScan(cwd, finder);
- });
-
- const list: WorkspaceSearchIndex["Service"]["list"] = Effect.fn("WorkspaceSearchIndex.list")(
- function* () {
- const result = yield* runMixedSearch("", WORKSPACE_INDEX_PAGE_SIZE);
- const mapped = mapMixedSearchResult(result, WORKSPACE_INDEX_MAX_ENTRIES);
- const sortedEntries = withDirectoryAncestors(mapped.entries).toSorted((left, right) =>
- left.path.localeCompare(right.path),
- );
- const entries = sortedEntries.slice(0, WORKSPACE_INDEX_MAX_ENTRIES);
- return {
- entries,
- truncated: mapped.truncated || entries.length < sortedEntries.length,
- };
- },
- );
+export const make = Effect.fn("WorkspaceSearchIndex.make")(function* (cwd: string) {
+ const finder = yield* Effect.acquireRelease(createFinder(cwd), (finder) =>
+ Effect.sync(() => finder.destroy()),
+ );
+ yield* waitForScan(cwd, finder);
+
+ const runMixedSearch = Effect.fn("WorkspaceSearchIndex.runMixedSearch")(function* (
+ query: string,
+ pageSize: number,
+ ) {
+ const result = yield* Effect.sync(() => finder.mixedSearch(query, { pageSize }));
+ if (!result.ok) {
+ return yield* new WorkspaceSearchIndexSearchFailed({ cwd, reason: result.error });
+ }
+ return result.value;
+ });
- const search: WorkspaceSearchIndex["Service"]["search"] = Effect.fn(
- "WorkspaceSearchIndex.search",
- )(function* (query, limit) {
- const result = yield* runMixedSearch(query, Math.max(1, limit + 1));
- return mapMixedSearchResult(result, limit);
- });
+ const refresh: WorkspaceSearchIndex["Service"]["refresh"] = Effect.fn(
+ "WorkspaceSearchIndex.refresh",
+ )(function* () {
+ const result = yield* Effect.sync(() => finder.scanFiles());
+ if (!result.ok) {
+ return yield* new WorkspaceSearchIndexRefreshFailed({ cwd, reason: result.error });
+ }
+ yield* waitForScan(cwd, finder);
+ });
- return WorkspaceSearchIndex.of({ list, refresh, search });
- }),
+ const list: WorkspaceSearchIndex["Service"]["list"] = Effect.fn("WorkspaceSearchIndex.list")(
+ function* () {
+ const result = yield* runMixedSearch("", WORKSPACE_INDEX_PAGE_SIZE);
+ const mapped = mapMixedSearchResult(result, WORKSPACE_INDEX_MAX_ENTRIES);
+ const sortedEntries = withDirectoryAncestors(mapped.entries).toSorted((left, right) =>
+ left.path.localeCompare(right.path),
+ );
+ const entries = sortedEntries.slice(0, WORKSPACE_INDEX_MAX_ENTRIES);
+ return {
+ entries,
+ truncated: mapped.truncated || entries.length < sortedEntries.length,
+ };
+ },
);
-const workspaceSearchIndexLayer = (cwd: string) =>
- Layer.effect(WorkspaceSearchIndex, makeWorkspaceSearchIndex(cwd));
+ const search: WorkspaceSearchIndex["Service"]["search"] = Effect.fn(
+ "WorkspaceSearchIndex.search",
+ )(function* (query, limit) {
+ const result = yield* runMixedSearch(query, Math.max(1, limit + 1));
+ return mapMixedSearchResult(result, limit);
+ });
+
+ return WorkspaceSearchIndex.of({ list, refresh, search });
+});
+
+/**
+ * A layer factory is required because every index is scoped to a concrete
+ * workspace root. WorkspaceSearchIndexMap owns memoization and idle cleanup;
+ * using a default cwd here would mix resources from different workspaces.
+ */
+export const layer = (cwd: string) => Layer.effect(WorkspaceSearchIndex, make(cwd));
export class WorkspaceSearchIndexMap extends LayerMap.Service()(
"t3/workspace/WorkspaceSearchIndexMap",
{
- lookup: workspaceSearchIndexLayer,
+ lookup: layer,
idleTimeToLive: WORKSPACE_INDEX_IDLE_TTL,
},
) {}
diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts
index 0b25b25f6f6..935dd47cc85 100644
--- a/apps/server/src/ws.ts
+++ b/apps/server/src/ws.ts
@@ -79,15 +79,15 @@ import * as PreviewManager from "./preview/Manager.ts";
import { issueAssetUrl } from "./assets/AssetAccess.ts";
import * as PortScanner from "./preview/PortScanner.ts";
import * as WorkspaceEntries from "./workspace/WorkspaceEntries.ts";
-import * as WorkspaceFileSystem from "./workspace/Services/WorkspaceFileSystem.ts";
-import * as WorkspacePaths from "./workspace/Services/WorkspacePaths.ts";
+import * as WorkspaceFileSystem from "./workspace/WorkspaceFileSystem.ts";
+import * as WorkspacePaths from "./workspace/WorkspacePaths.ts";
import * as VcsStatusBroadcaster from "./vcs/VcsStatusBroadcaster.ts";
import * as VcsProvisioningService from "./vcs/VcsProvisioningService.ts";
import * as GitWorkflowService from "./git/GitWorkflowService.ts";
import * as ReviewService from "./review/ReviewService.ts";
-import * as ProjectSetupScriptRunner from "./project/Services/ProjectSetupScriptRunner.ts";
-import * as RepositoryIdentityResolver from "./project/Services/RepositoryIdentityResolver.ts";
-import * as ServerEnvironment from "./environment/Services/ServerEnvironment.ts";
+import * as ProjectSetupScriptRunner from "./project/ProjectSetupScriptRunner.ts";
+import * as RepositoryIdentityResolver from "./project/RepositoryIdentityResolver.ts";
+import * as ServerEnvironment from "./environment/ServerEnvironment.ts";
import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts";
import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts";
import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts";
@@ -1190,7 +1190,7 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) =>
Effect.mapError(
(cause) =>
new ProjectSearchEntriesError({
- message: `Failed to search workspace entries: ${cause.detail}`,
+ message: "Failed to search workspace entries.",
cause,
}),
),
@@ -1204,7 +1204,7 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) =>
Effect.mapError(
(cause) =>
new ProjectListEntriesError({
- message: `Failed to list workspace entries: ${cause.detail}`,
+ message: "Failed to list workspace entries.",
cause,
}),
),
@@ -1218,7 +1218,7 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) =>
Effect.mapError((cause) => {
const message = isWorkspacePathOutsideRootError(cause)
? "Workspace file path must stay within the project root."
- : `Failed to read workspace file: ${cause.detail}`;
+ : "Failed to read workspace file.";
return new ProjectReadFileError({ message, cause });
}),
),
@@ -1251,7 +1251,7 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) =>
Effect.mapError(
(cause) =>
new FilesystemBrowseError({
- message: cause.detail,
+ message: "Failed to browse the filesystem.",
cause,
}),
),