diff --git a/apps/server/integration/OrchestrationEngineHarness.integration.ts b/apps/server/integration/OrchestrationEngineHarness.integration.ts index fa388ba052f3a0e15a54d6c2b9e13733663bfb60..292b267e124684451d68beef2c71422bf5dd7f9a 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 29b1db25118087a2d3537b1164ceb8dcb9bb673a..0cbe9176582266d6604aa5aad4eb79d354523625 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 ae5086e97354824cd2e62f07b5590c2073e09a4a..cf3c40f57c7d6a22852328673d3a92548b6ba747 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 64d366468f99d81422a2c55cbba7f3f2a6677bad..d71bc83f94e5f7ae160817287882c9e3e4b34f87 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 51582965913e2c087dfd131a57ca13c432529569..314680b0d80184f4d1695e723004b1280230d52e 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 eec7f3f55412cf7680d2176b6ff9431a2e2106b8..d52d5b214d808712ae65b4fa1831921cebc2885b 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 3a8586f150a7f96f660c0e345771c12b66b99bda..58274c9d708b12dc227fb19caba32097d1bef768 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 86716b69a35dff5efa3bfccd395c92ac4072c84d..64c87f3487caa7b2420d6e41edfb70aff902b7b6 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/Layers/ServerEnvironment.test.ts deleted file mode 100644 index 3bb96a83e1cfb1ed780d758d25adb419c17988c3..0000000000000000000000000000000000000000 --- a/apps/server/src/environment/Layers/ServerEnvironment.test.ts +++ /dev/null @@ -1,130 +0,0 @@ -// @effect-diagnostics nodeBuiltinImport:off -import * as nodePath from "node:path"; -import * as NodeServices from "@effect/platform-node/NodeServices"; -import { expect, it } from "@effect/vitest"; -import * as Effect from "effect/Effect"; -import * as Exit from "effect/Exit"; -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"; - -const makeServerEnvironmentLayer = (baseDir: string) => - ServerEnvironmentLive.pipe(Layer.provide(ServerConfig.layerTest(process.cwd(), baseDir))); - -const makeServerConfig = Effect.fn(function* (baseDir: string) { - const derivedPaths = yield* ServerConfig.deriveServerPaths(baseDir, undefined); - - return { - ...derivedPaths, - logLevel: "Error", - traceMinLevel: "Info", - traceTimingEnabled: true, - traceBatchWindowMs: 200, - traceMaxBytes: 10 * 1024 * 1024, - traceMaxFiles: 10, - otlpTracesUrl: undefined, - otlpMetricsUrl: undefined, - otlpExportIntervalMs: 10_000, - otlpServiceName: "t3-server", - cwd: process.cwd(), - baseDir, - mode: "web", - autoBootstrapProjectFromCwd: false, - logWebSocketEvents: false, - tailscaleServeEnabled: false, - tailscaleServePort: 443, - port: 0, - host: undefined, - desktopBootstrapToken: undefined, - staticDir: undefined, - devUrl: undefined, - noBrowser: false, - startupPresentation: "browser", - } satisfies ServerConfig.ServerConfig["Service"]; -}); - -it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => { - it.effect("persists the environment id across service restarts", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const baseDir = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-server-environment-test-", - }); - - const first = yield* Effect.gen(function* () { - const serverEnvironment = yield* ServerEnvironment.ServerEnvironment; - return yield* serverEnvironment.getDescriptor; - }).pipe(Effect.provide(makeServerEnvironmentLayer(baseDir))); - const second = yield* Effect.gen(function* () { - const serverEnvironment = yield* ServerEnvironment.ServerEnvironment; - return yield* serverEnvironment.getDescriptor; - }).pipe(Effect.provide(makeServerEnvironmentLayer(baseDir))); - - expect(first.environmentId).toBe(second.environmentId); - expect(second.capabilities.repositoryIdentity).toBe(true); - }), - ); - - it.effect("fails instead of overwriting a persisted id when reading the file errors", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const baseDir = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-server-environment-read-error-test-", - }); - const serverConfig = yield* makeServerConfig(baseDir); - const environmentIdPath = serverConfig.environmentIdPath; - yield* fileSystem.makeDirectory(nodePath.dirname(environmentIdPath), { recursive: true }); - yield* fileSystem.writeFileString(environmentIdPath, "persisted-environment-id\n"); - const writeAttempts: string[] = []; - const failingFileSystemLayer = FileSystem.layerNoop({ - exists: (path) => Effect.succeed(path === environmentIdPath), - readFileString: (path) => - path === environmentIdPath - ? Effect.fail( - PlatformError.systemError({ - _tag: "PermissionDenied", - module: "FileSystem", - method: "readFileString", - description: "permission denied", - pathOrDescriptor: path, - }), - ) - : Effect.fail( - PlatformError.systemError({ - _tag: "NotFound", - module: "FileSystem", - method: "readFileString", - description: "not found", - pathOrDescriptor: path, - }), - ), - writeFileString: (path) => { - writeAttempts.push(path); - return Effect.void; - }, - }); - - const exit = yield* Effect.gen(function* () { - const serverEnvironment = yield* ServerEnvironment.ServerEnvironment; - return yield* serverEnvironment.getDescriptor; - }).pipe( - Effect.provide( - ServerEnvironmentLive.pipe( - Layer.provide(Layer.merge(ServerConfig.layer(serverConfig), failingFileSystemLayer)), - ), - ), - Effect.exit, - ); - - expect(Exit.isFailure(exit)).toBe(true); - expect(writeAttempts).toEqual([]); - expect(yield* fileSystem.readFileString(environmentIdPath)).toBe( - "persisted-environment-id\n", - ); - }), - ); -}); diff --git a/apps/server/src/environment/Layers/ServerEnvironment.ts b/apps/server/src/environment/Layers/ServerEnvironment.ts deleted file mode 100644 index fd4f6baab1ab91a50cbc006b9c2265f45e9f842a..0000000000000000000000000000000000000000 --- a/apps/server/src/environment/Layers/ServerEnvironment.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { EnvironmentId, type ExecutionEnvironmentDescriptor } from "@t3tools/contracts"; -import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess"; -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 { resolveServerEnvironmentLabel } from "./ServerEnvironmentLabel.ts"; - -function platformOs(platform: NodeJS.Platform): ExecutionEnvironmentDescriptor["platform"]["os"] { - switch (platform) { - case "darwin": - return "darwin"; - case "linux": - return "linux"; - case "win32": - return "windows"; - default: - return "unknown"; - } -} - -function platformArch( - architecture: NodeJS.Architecture, -): ExecutionEnvironmentDescriptor["platform"]["arch"] { - switch (architecture) { - case "arm64": - return "arm64"; - case "x64": - return "x64"; - default: - return "other"; - } -} - -export const makeServerEnvironment = Effect.fn("makeServerEnvironment")(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const serverConfig = yield* ServerConfig; - const crypto = yield* Crypto.Crypto; - const hostPlatform = yield* HostProcessPlatform; - const hostArchitecture = yield* HostProcessArchitecture; - - const readPersistedEnvironmentId = Effect.gen(function* () { - const exists = yield* fileSystem - .exists(serverConfig.environmentIdPath) - .pipe(Effect.orElseSucceed(() => false)); - if (!exists) { - return null; - } - - const raw = yield* fileSystem - .readFileString(serverConfig.environmentIdPath) - .pipe(Effect.map((value) => value.trim())); - - return raw.length > 0 ? raw : null; - }); - - const persistEnvironmentId = (value: string) => - fileSystem.writeFileString(serverConfig.environmentIdPath, `${value}\n`); - - const environmentIdRaw = yield* Effect.gen(function* () { - const persisted = yield* readPersistedEnvironmentId; - if (persisted) { - return persisted; - } - - const generated = yield* crypto.randomUUIDv4; - yield* persistEnvironmentId(generated); - return generated; - }); - - const environmentId = EnvironmentId.make(environmentIdRaw); - const cwdBaseName = path.basename(serverConfig.cwd).trim(); - const label = yield* resolveServerEnvironmentLabel({ - cwdBaseName, - }); - - const descriptor: ExecutionEnvironmentDescriptor = { - environmentId, - label, - platform: { - os: platformOs(hostPlatform), - arch: platformArch(hostArchitecture), - }, - serverVersion: packageJson.version, - capabilities: { - repositoryIdentity: true, - }, - }; - - return { - getEnvironmentId: Effect.succeed(environmentId), - getDescriptor: Effect.succeed(descriptor), - } satisfies ServerEnvironmentShape; -}); - -export const ServerEnvironmentLive = Layer.effect(ServerEnvironment, makeServerEnvironment()).pipe( - Layer.provide(ProcessRunnerLive), -); diff --git a/apps/server/src/environment/Layers/ServerEnvironmentLabel.test.ts b/apps/server/src/environment/Layers/ServerEnvironmentLabel.test.ts deleted file mode 100644 index 14580369a78506ca6d5dad2c85e5f82847cbd3c5..0000000000000000000000000000000000000000 --- a/apps/server/src/environment/Layers/ServerEnvironmentLabel.test.ts +++ /dev/null @@ -1,175 +0,0 @@ -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 { HostProcessHostname, HostProcessPlatform } from "@t3tools/shared/hostProcess"; -import { vi } from "vite-plus/test"; - -import * as ProcessRunner from "../../processRunner.ts"; -import { resolveServerEnvironmentLabel } from "./ServerEnvironmentLabel.ts"; -import { ChildProcessSpawner } from "effect/unstable/process"; - -const runMock = vi.fn(); - -const ProcessRunnerTest = Layer.succeed( - ProcessRunner.ProcessRunner, - ProcessRunner.ProcessRunner.of({ - run: (input) => runMock(input), - }), -); -const NoopFileSystemLayer = FileSystem.layerNoop({}); -const TestLayer = Layer.merge(NoopFileSystemLayer, ProcessRunnerTest); -const LinuxMachineInfoLayer = Layer.merge( - ProcessRunnerTest, - FileSystem.layerNoop({ - exists: (path) => Effect.succeed(path === "/etc/machine-info"), - readFileString: (path) => - path === "/etc/machine-info" - ? Effect.succeed('PRETTY_HOSTNAME="Build Agent 01"\nICON_NAME="computer-vm"\n') - : Effect.succeed(""), - }), -); -const withHostPlatform = ( - layer: Layer.Layer, - platform: NodeJS.Platform, - hostname: string, -) => - Layer.mergeAll( - layer, - Layer.succeed(HostProcessPlatform, platform), - Layer.succeed(HostProcessHostname, hostname), - ); - -afterEach(() => { - runMock.mockReset(); -}); - -describe("resolveServerEnvironmentLabel", () => { - it.effect("uses hostname fallback regardless of launch mode", () => - Effect.gen(function* () { - const result = yield* resolveServerEnvironmentLabel({ - cwdBaseName: "t3code", - }).pipe(Effect.provide(withHostPlatform(TestLayer, "win32", "macbook-pro"))); - - expect(result).toBe("macbook-pro"); - }), - ); - - it.effect("prefers the macOS ComputerName", () => - Effect.gen(function* () { - runMock.mockReturnValueOnce( - Effect.succeed({ - stdout: " Julius's MacBook Pro \n", - stderr: "", - code: ChildProcessSpawner.ExitCode(0), - timedOut: false, - stdoutTruncated: false, - stderrTruncated: false, - }), - ); - - const result = yield* resolveServerEnvironmentLabel({ - cwdBaseName: "t3code", - }).pipe(Effect.provide(withHostPlatform(TestLayer, "darwin", "macbook-pro"))); - - expect(result).toBe("Julius's MacBook Pro"); - expect(runMock).toHaveBeenCalledWith( - expect.objectContaining({ - command: "scutil", - args: ["--get", "ComputerName"], - timeoutBehavior: "timedOutResult", - }), - ); - }), - ); - - it.effect("prefers Linux PRETTY_HOSTNAME from machine-info", () => - Effect.gen(function* () { - const result = yield* resolveServerEnvironmentLabel({ - cwdBaseName: "t3code", - }).pipe(Effect.provide(withHostPlatform(LinuxMachineInfoLayer, "linux", "buildbox"))); - - expect(result).toBe("Build Agent 01"); - expect(runMock).not.toHaveBeenCalled(); - }), - ); - - it.effect("falls back to hostnamectl pretty hostname on Linux", () => - Effect.gen(function* () { - runMock.mockReturnValueOnce( - Effect.succeed({ - stdout: "CI Runner\n", - stderr: "", - code: ChildProcessSpawner.ExitCode(0), - timedOut: false, - stdoutTruncated: false, - stderrTruncated: false, - }), - ); - - const result = yield* resolveServerEnvironmentLabel({ - cwdBaseName: "t3code", - }).pipe(Effect.provide(withHostPlatform(TestLayer, "linux", "runner-01"))); - - expect(result).toBe("CI Runner"); - expect(runMock).toHaveBeenCalledWith( - expect.objectContaining({ - command: "hostnamectl", - args: ["--pretty"], - timeoutBehavior: "timedOutResult", - }), - ); - }), - ); - - it.effect("falls back to the hostname when friendly labels are unavailable", () => - Effect.gen(function* () { - const result = yield* resolveServerEnvironmentLabel({ - cwdBaseName: "t3code", - }).pipe(Effect.provide(withHostPlatform(TestLayer, "win32", "JULIUS-LAPTOP"))); - - expect(result).toBe("JULIUS-LAPTOP"); - }), - ); - - it.effect("falls back to the hostname when the friendly-label command is missing", () => - Effect.gen(function* () { - runMock.mockReturnValueOnce( - Effect.fail( - new ProcessRunner.ProcessSpawnError({ - command: "scutil", - args: ["--get", "ComputerName"], - cause: new Error("spawn scutil ENOENT"), - }), - ), - ); - - const result = yield* resolveServerEnvironmentLabel({ - cwdBaseName: "t3code", - }).pipe(Effect.provide(withHostPlatform(TestLayer, "darwin", "macbook-pro"))); - - expect(result).toBe("macbook-pro"); - }), - ); - - it.effect("falls back to the cwd basename when the hostname is blank", () => - Effect.gen(function* () { - runMock.mockReturnValueOnce( - Effect.succeed({ - stdout: " ", - stderr: "", - code: ChildProcessSpawner.ExitCode(0), - timedOut: false, - stdoutTruncated: false, - stderrTruncated: false, - }), - ); - - const result = yield* resolveServerEnvironmentLabel({ - cwdBaseName: "t3code", - }).pipe(Effect.provide(withHostPlatform(TestLayer, "linux", " "))); - - expect(result).toBe("t3code"); - }), - ); -}); diff --git a/apps/server/src/environment/Layers/ServerEnvironmentLabel.ts b/apps/server/src/environment/Layers/ServerEnvironmentLabel.ts deleted file mode 100644 index 73a3b9526c4c62ab4e647bca4f2b18caa5cd58e8..0000000000000000000000000000000000000000 --- a/apps/server/src/environment/Layers/ServerEnvironmentLabel.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { HostProcessHostname, HostProcessPlatform } from "@t3tools/shared/hostProcess"; -import * as Effect from "effect/Effect"; -import * as FileSystem from "effect/FileSystem"; -import * as Option from "effect/Option"; - -import { ProcessRunner } from "../../processRunner.ts"; - -interface ResolveServerEnvironmentLabelInput { - readonly cwdBaseName: string; -} - -function normalizeLabel(value: string | null | undefined): string | null { - const trimmed = value?.trim(); - return trimmed && trimmed.length > 0 ? trimmed : null; -} - -function parseMachineInfoValue(raw: string, key: string): string | null { - for (const line of raw.split(/\r?\n/g)) { - const trimmed = line.trim(); - if (trimmed.length === 0 || trimmed.startsWith("#") || !trimmed.startsWith(`${key}=`)) { - continue; - } - const value = trimmed.slice(key.length + 1).trim(); - if ( - (value.startsWith('"') && value.endsWith('"')) || - (value.startsWith("'") && value.endsWith("'")) - ) { - return normalizeLabel(value.slice(1, -1)); - } - return normalizeLabel(value); - } - return null; -} - -const readLinuxMachineInfo = Effect.fn("readLinuxMachineInfo")(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const exists = yield* fileSystem - .exists("/etc/machine-info") - .pipe(Effect.orElseSucceed(() => false)); - if (!exists) { - return null; - } - - return yield* fileSystem - .readFileString("/etc/machine-info") - .pipe(Effect.orElseSucceed(() => null)); -}); - -const runFriendlyLabelCommand = Effect.fn("runFriendlyLabelCommand")(function* ( - command: string, - args: readonly string[], -) { - const processRunner = yield* ProcessRunner; - const result = yield* processRunner - .run({ - command, - args, - timeoutBehavior: "timedOutResult", - }) - .pipe(Effect.option); - - if (Option.isNone(result) || result.value.code !== 0) { - return null; - } - - return normalizeLabel(result.value.stdout); -}); - -const resolveFriendlyHostLabel = Effect.fn("resolveFriendlyHostLabel")(function* () { - const platform = yield* HostProcessPlatform; - if (platform === "darwin") { - return yield* runFriendlyLabelCommand("scutil", ["--get", "ComputerName"]); - } - - if (platform === "linux") { - const machineInfo = normalizeLabel(yield* readLinuxMachineInfo()); - if (machineInfo) { - const prettyHostname = parseMachineInfoValue(machineInfo, "PRETTY_HOSTNAME"); - if (prettyHostname) { - return prettyHostname; - } - } - - return yield* runFriendlyLabelCommand("hostnamectl", ["--pretty"]); - } - - return null; -}); - -export const resolveServerEnvironmentLabel = Effect.fn("resolveServerEnvironmentLabel")(function* ( - input: ResolveServerEnvironmentLabelInput, -) { - const friendlyHostLabel = yield* resolveFriendlyHostLabel(); - if (friendlyHostLabel) { - return friendlyHostLabel; - } - - const hostname = normalizeLabel(yield* HostProcessHostname); - if (hostname) { - return hostname; - } - - return normalizeLabel(input.cwdBaseName) ?? "T3 environment"; -}); diff --git a/apps/server/src/environment/ServerEnvironment.test.ts b/apps/server/src/environment/ServerEnvironment.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..3b0cef13bf9cc524ff407164272bc59861f857f9 --- /dev/null +++ b/apps/server/src/environment/ServerEnvironment.test.ts @@ -0,0 +1,129 @@ +// @effect-diagnostics nodeBuiltinImport:off +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"; +import * as Exit from "effect/Exit"; +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 "./ServerEnvironment.ts"; + +const makeServerEnvironmentLayer = (baseDir: string) => + ServerEnvironment.layer.pipe(Layer.provide(ServerConfig.layerTest(process.cwd(), baseDir))); + +const makeServerConfig = Effect.fn(function* (baseDir: string) { + const derivedPaths = yield* ServerConfig.deriveServerPaths(baseDir, undefined); + + return { + ...derivedPaths, + logLevel: "Error", + traceMinLevel: "Info", + traceTimingEnabled: true, + traceBatchWindowMs: 200, + traceMaxBytes: 10 * 1024 * 1024, + traceMaxFiles: 10, + otlpTracesUrl: undefined, + otlpMetricsUrl: undefined, + otlpExportIntervalMs: 10_000, + otlpServiceName: "t3-server", + cwd: process.cwd(), + baseDir, + mode: "web", + autoBootstrapProjectFromCwd: false, + logWebSocketEvents: false, + tailscaleServeEnabled: false, + tailscaleServePort: 443, + port: 0, + host: undefined, + desktopBootstrapToken: undefined, + staticDir: undefined, + devUrl: undefined, + noBrowser: false, + startupPresentation: "browser", + } satisfies ServerConfig.ServerConfig["Service"]; +}); + +it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => { + it.effect("persists the environment id across service restarts", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-server-environment-test-", + }); + + const first = yield* Effect.gen(function* () { + const serverEnvironment = yield* ServerEnvironment.ServerEnvironment; + return yield* serverEnvironment.getDescriptor; + }).pipe(Effect.provide(makeServerEnvironmentLayer(baseDir))); + const second = yield* Effect.gen(function* () { + const serverEnvironment = yield* ServerEnvironment.ServerEnvironment; + return yield* serverEnvironment.getDescriptor; + }).pipe(Effect.provide(makeServerEnvironmentLayer(baseDir))); + + expect(first.environmentId).toBe(second.environmentId); + expect(second.capabilities.repositoryIdentity).toBe(true); + }), + ); + + it.effect("fails instead of overwriting a persisted id when reading the file errors", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-server-environment-read-error-test-", + }); + const serverConfig = yield* makeServerConfig(baseDir); + const environmentIdPath = serverConfig.environmentIdPath; + yield* fileSystem.makeDirectory(dirname(environmentIdPath), { recursive: true }); + yield* fileSystem.writeFileString(environmentIdPath, "persisted-environment-id\n"); + const writeAttempts: string[] = []; + const failingFileSystemLayer = FileSystem.layerNoop({ + exists: (path) => Effect.succeed(path === environmentIdPath), + readFileString: (path) => + path === environmentIdPath + ? Effect.fail( + PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method: "readFileString", + description: "permission denied", + pathOrDescriptor: path, + }), + ) + : Effect.fail( + PlatformError.systemError({ + _tag: "NotFound", + module: "FileSystem", + method: "readFileString", + description: "not found", + pathOrDescriptor: path, + }), + ), + writeFileString: (path) => { + writeAttempts.push(path); + return Effect.void; + }, + }); + + const exit = yield* Effect.gen(function* () { + const serverEnvironment = yield* ServerEnvironment.ServerEnvironment; + return yield* serverEnvironment.getDescriptor; + }).pipe( + Effect.provide( + ServerEnvironment.layer.pipe( + Layer.provide(Layer.merge(ServerConfig.layer(serverConfig), failingFileSystemLayer)), + ), + ), + Effect.exit, + ); + + expect(Exit.isFailure(exit)).toBe(true); + expect(writeAttempts).toEqual([]); + expect(yield* fileSystem.readFileString(environmentIdPath)).toBe( + "persisted-environment-id\n", + ); + }), + ); +}); diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts new file mode 100644 index 0000000000000000000000000000000000000000..433a9d3f02ae1e2ccc964d7d219ad018502351d0 --- /dev/null +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -0,0 +1,114 @@ +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 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": + return "darwin"; + case "linux": + return "linux"; + case "win32": + return "windows"; + default: + return "unknown"; + } +} + +function platformArch( + architecture: NodeJS.Architecture, +): ExecutionEnvironmentDescriptor["platform"]["arch"] { + switch (architecture) { + case "arm64": + return "arm64"; + case "x64": + return "x64"; + default: + return "other"; + } +} + +export const make = Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const serverConfig = yield* ServerConfig.ServerConfig; + const crypto = yield* Crypto.Crypto; + const hostPlatform = yield* HostProcessPlatform; + const hostArchitecture = yield* HostProcessArchitecture; + + const readPersistedEnvironmentId = Effect.gen(function* () { + const exists = yield* fileSystem + .exists(serverConfig.environmentIdPath) + .pipe(Effect.orElseSucceed(() => false)); + if (!exists) { + return null; + } + + const raw = yield* fileSystem + .readFileString(serverConfig.environmentIdPath) + .pipe(Effect.map((value) => value.trim())); + + return raw.length > 0 ? raw : null; + }); + + const persistEnvironmentId = (value: string) => + fileSystem.writeFileString(serverConfig.environmentIdPath, `${value}\n`); + + const environmentIdRaw = yield* Effect.gen(function* () { + const persisted = yield* readPersistedEnvironmentId; + if (persisted) { + return persisted; + } + + const generated = yield* crypto.randomUUIDv4; + yield* persistEnvironmentId(generated); + return generated; + }); + + const environmentId = EnvironmentId.make(environmentIdRaw); + const cwdBaseName = path.basename(serverConfig.cwd).trim(); + const label = yield* resolveServerEnvironmentLabel({ cwdBaseName }); + + const descriptor: ExecutionEnvironmentDescriptor = { + environmentId, + label, + platform: { + os: platformOs(hostPlatform), + arch: platformArch(hostArchitecture), + }, + serverVersion: packageJson.version, + capabilities: { + repositoryIdentity: true, + }, + }; + + return ServerEnvironment.of({ + getEnvironmentId: Effect.succeed(environmentId), + getDescriptor: Effect.succeed(descriptor), + }); +}); + +/** + * 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/ServerEnvironmentLabel.test.ts b/apps/server/src/environment/ServerEnvironmentLabel.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..bc30bd0ce1953c285bd9f37c575b5e8dc795153f --- /dev/null +++ b/apps/server/src/environment/ServerEnvironmentLabel.test.ts @@ -0,0 +1,175 @@ +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 { resolveServerEnvironmentLabel } from "./ServerEnvironmentLabel.ts"; + +const runMock = vi.fn(); + +const ProcessRunnerTest = Layer.succeed( + ProcessRunner.ProcessRunner, + ProcessRunner.ProcessRunner.of({ + run: (input) => runMock(input), + }), +); +const NoopFileSystemLayer = FileSystem.layerNoop({}); +const TestLayer = Layer.merge(NoopFileSystemLayer, ProcessRunnerTest); +const LinuxMachineInfoLayer = Layer.merge( + ProcessRunnerTest, + FileSystem.layerNoop({ + exists: (path) => Effect.succeed(path === "/etc/machine-info"), + readFileString: (path) => + path === "/etc/machine-info" + ? Effect.succeed('PRETTY_HOSTNAME="Build Agent 01"\nICON_NAME="computer-vm"\n') + : Effect.succeed(""), + }), +); +const withHostPlatform = ( + layer: Layer.Layer, + platform: NodeJS.Platform, + hostname: string, +) => + Layer.mergeAll( + layer, + Layer.succeed(HostProcessPlatform, platform), + Layer.succeed(HostProcessHostname, hostname), + ); + +afterEach(() => { + runMock.mockReset(); +}); + +describe("resolveServerEnvironmentLabel", () => { + it.effect("uses hostname fallback regardless of launch mode", () => + Effect.gen(function* () { + const result = yield* resolveServerEnvironmentLabel({ + cwdBaseName: "t3code", + }).pipe(Effect.provide(withHostPlatform(TestLayer, "win32", "macbook-pro"))); + + expect(result).toBe("macbook-pro"); + }), + ); + + it.effect("prefers the macOS ComputerName", () => + Effect.gen(function* () { + runMock.mockReturnValueOnce( + Effect.succeed({ + stdout: " Julius's MacBook Pro \n", + stderr: "", + code: ChildProcessSpawner.ExitCode(0), + timedOut: false, + stdoutTruncated: false, + stderrTruncated: false, + }), + ); + + const result = yield* resolveServerEnvironmentLabel({ + cwdBaseName: "t3code", + }).pipe(Effect.provide(withHostPlatform(TestLayer, "darwin", "macbook-pro"))); + + expect(result).toBe("Julius's MacBook Pro"); + expect(runMock).toHaveBeenCalledWith( + expect.objectContaining({ + command: "scutil", + args: ["--get", "ComputerName"], + timeoutBehavior: "timedOutResult", + }), + ); + }), + ); + + it.effect("prefers Linux PRETTY_HOSTNAME from machine-info", () => + Effect.gen(function* () { + const result = yield* resolveServerEnvironmentLabel({ + cwdBaseName: "t3code", + }).pipe(Effect.provide(withHostPlatform(LinuxMachineInfoLayer, "linux", "buildbox"))); + + expect(result).toBe("Build Agent 01"); + expect(runMock).not.toHaveBeenCalled(); + }), + ); + + it.effect("falls back to hostnamectl pretty hostname on Linux", () => + Effect.gen(function* () { + runMock.mockReturnValueOnce( + Effect.succeed({ + stdout: "CI Runner\n", + stderr: "", + code: ChildProcessSpawner.ExitCode(0), + timedOut: false, + stdoutTruncated: false, + stderrTruncated: false, + }), + ); + + const result = yield* resolveServerEnvironmentLabel({ + cwdBaseName: "t3code", + }).pipe(Effect.provide(withHostPlatform(TestLayer, "linux", "runner-01"))); + + expect(result).toBe("CI Runner"); + expect(runMock).toHaveBeenCalledWith( + expect.objectContaining({ + command: "hostnamectl", + args: ["--pretty"], + timeoutBehavior: "timedOutResult", + }), + ); + }), + ); + + it.effect("falls back to the hostname when friendly labels are unavailable", () => + Effect.gen(function* () { + const result = yield* resolveServerEnvironmentLabel({ + cwdBaseName: "t3code", + }).pipe(Effect.provide(withHostPlatform(TestLayer, "win32", "JULIUS-LAPTOP"))); + + expect(result).toBe("JULIUS-LAPTOP"); + }), + ); + + it.effect("falls back to the hostname when the friendly-label command is missing", () => + Effect.gen(function* () { + runMock.mockReturnValueOnce( + Effect.fail( + new ProcessRunner.ProcessSpawnError({ + command: "scutil", + args: ["--get", "ComputerName"], + cause: new Error("spawn scutil ENOENT"), + }), + ), + ); + + const result = yield* resolveServerEnvironmentLabel({ + cwdBaseName: "t3code", + }).pipe(Effect.provide(withHostPlatform(TestLayer, "darwin", "macbook-pro"))); + + expect(result).toBe("macbook-pro"); + }), + ); + + it.effect("falls back to the cwd basename when the hostname is blank", () => + Effect.gen(function* () { + runMock.mockReturnValueOnce( + Effect.succeed({ + stdout: " ", + stderr: "", + code: ChildProcessSpawner.ExitCode(0), + timedOut: false, + stdoutTruncated: false, + stderrTruncated: false, + }), + ); + + const result = yield* resolveServerEnvironmentLabel({ + cwdBaseName: "t3code", + }).pipe(Effect.provide(withHostPlatform(TestLayer, "linux", " "))); + + expect(result).toBe("t3code"); + }), + ); +}); diff --git a/apps/server/src/environment/ServerEnvironmentLabel.ts b/apps/server/src/environment/ServerEnvironmentLabel.ts new file mode 100644 index 0000000000000000000000000000000000000000..83c3b8bad8e60f5b682a34a63f9b6f479d8aac9b --- /dev/null +++ b/apps/server/src/environment/ServerEnvironmentLabel.ts @@ -0,0 +1,104 @@ +import { HostProcessHostname, HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; + +import * as ProcessRunner from "../processRunner.ts"; + +interface ResolveServerEnvironmentLabelInput { + readonly cwdBaseName: string; +} + +function normalizeLabel(value: string | null | undefined): string | null { + const trimmed = value?.trim(); + return trimmed && trimmed.length > 0 ? trimmed : null; +} + +function parseMachineInfoValue(raw: string, key: string): string | null { + for (const line of raw.split(/\r?\n/g)) { + const trimmed = line.trim(); + if (trimmed.length === 0 || trimmed.startsWith("#") || !trimmed.startsWith(`${key}=`)) { + continue; + } + const value = trimmed.slice(key.length + 1).trim(); + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { + return normalizeLabel(value.slice(1, -1)); + } + return normalizeLabel(value); + } + return null; +} + +const readLinuxMachineInfo = Effect.fn("readLinuxMachineInfo")(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const exists = yield* fileSystem + .exists("/etc/machine-info") + .pipe(Effect.orElseSucceed(() => false)); + if (!exists) { + return null; + } + + return yield* fileSystem + .readFileString("/etc/machine-info") + .pipe(Effect.orElseSucceed(() => null)); +}); + +const runFriendlyLabelCommand = Effect.fn("runFriendlyLabelCommand")(function* ( + command: string, + args: readonly string[], +) { + const processRunner = yield* ProcessRunner.ProcessRunner; + const result = yield* processRunner + .run({ + command, + args, + timeoutBehavior: "timedOutResult", + }) + .pipe(Effect.option); + + if (Option.isNone(result) || result.value.code !== 0) { + return null; + } + + return normalizeLabel(result.value.stdout); +}); + +const resolveFriendlyHostLabel = Effect.fn("resolveFriendlyHostLabel")(function* () { + const platform = yield* HostProcessPlatform; + if (platform === "darwin") { + return yield* runFriendlyLabelCommand("scutil", ["--get", "ComputerName"]); + } + + if (platform === "linux") { + const machineInfo = normalizeLabel(yield* readLinuxMachineInfo()); + if (machineInfo) { + const prettyHostname = parseMachineInfoValue(machineInfo, "PRETTY_HOSTNAME"); + if (prettyHostname) { + return prettyHostname; + } + } + + return yield* runFriendlyLabelCommand("hostnamectl", ["--pretty"]); + } + + return null; +}); + +export const resolveServerEnvironmentLabel = Effect.fn("resolveServerEnvironmentLabel")(function* ( + input: ResolveServerEnvironmentLabelInput, +) { + const friendlyHostLabel = yield* resolveFriendlyHostLabel(); + if (friendlyHostLabel) { + return friendlyHostLabel; + } + + const hostname = normalizeLabel(yield* HostProcessHostname); + if (hostname) { + return hostname; + } + + return normalizeLabel(input.cwdBaseName) ?? "T3 environment"; +}); diff --git a/apps/server/src/environment/Services/ServerEnvironment.ts b/apps/server/src/environment/Services/ServerEnvironment.ts deleted file mode 100644 index 1e6dea0d05f10bf4008271f42a6adebbdbe4306f..0000000000000000000000000000000000000000 --- 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 2b296e5f3fa666453c80f8c5d0e53ab8f8c6e8a2..3ff9a42390e88b9f8c85891454a2bfa81bdbad8b 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 c57c814f4377595c97be7906e0ccba22a02796ce..88eb0e2128208ef8567eb8c6201050edffe5af1e 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 032fd501b01c1455e2591d3fa2e505c619648549..0528d5e523d31e6ea786b41f40d9662e907988e0 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 7616affaafd9ea07b8bf52ee4474dfdb8822f0cb..a91d98febd801579a6a9379f77e99802b9a29bd6 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 c15480310d5a4a61682fee75fcbda5de7f589fc0..de9dc9584154000075e2189d899fa590dd90eec3 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 4bb5afbb476daeff9cfacfdd2dd322e64f2e71c9..07c543264f70f9f6f30ed48e4a2c622ef569370c 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 56876ec148ed8ab912923ab573c1fd955f252f49..b2ef0fed0f9620a27a92a67f11d54c130dbe6f51 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 5a997de366984bb1d812851c10e80b5b4eed1037..0999000ed4f98e43bddf43f5b6260345b54ff007 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 7db2a23e5ec3b6669cb6a520c9c4358667934a36..9a136b06872f4babef590424a018b786a5d4817d 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 e629d1604b3e3b33f2c5d0a763c7572ed564b262..e36db35b107451009536f0b60ebb9aa3fba02302 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 0e399f03ab8f2629a360b70e6825d0f9461a041f..8041bc66dd30447f6681d84e3423384607d225a5 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 6495559023562f4dc39a3d0136dcdee469a5052e..2aaa7ea9a33eb01203ce4e431a046daf19458b03 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 95d29e3d6d294c7df521973b0b0764363ff804f4..bed166eba45d3850318c57343f63b26dd46761c6 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/ProjectFaviconResolver.test.ts b/apps/server/src/project/Layers/ProjectFaviconResolver.test.ts deleted file mode 100644 index 5c0e5d957425e93317b04a86fda15963806de153..0000000000000000000000000000000000000000 --- a/apps/server/src/project/Layers/ProjectFaviconResolver.test.ts +++ /dev/null @@ -1,78 +0,0 @@ -import * as NodeServices from "@effect/platform-node/NodeServices"; -import { it, describe, expect } from "@effect/vitest"; -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 } from "../Services/ProjectFaviconResolver.ts"; -import { ProjectFaviconResolverLive } from "./ProjectFaviconResolver.ts"; -import { WorkspacePathsLive } from "../../workspace/Layers/WorkspacePaths.ts"; - -const TestLayer = Layer.empty.pipe( - Layer.provideMerge(ProjectFaviconResolverLive.pipe(Layer.provide(WorkspacePathsLive))), - Layer.provideMerge(NodeServices.layer), -); - -const makeTempDir = Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - return yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3code-project-favicon-", - }); -}); - -const writeTextFile = Effect.fn("writeTextFile")(function* ( - cwd: string, - relativePath: string, - contents: string, -) { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const absolutePath = path.join(cwd, relativePath); - yield* fileSystem - .makeDirectory(path.dirname(absolutePath), { recursive: true }) - .pipe(Effect.orDie); - yield* fileSystem.writeFileString(absolutePath, contents).pipe(Effect.orDie); -}); - -it.layer(TestLayer)("ProjectFaviconResolverLive", (it) => { - describe("resolvePath", () => { - it.effect("prefers well-known favicon files", () => - Effect.gen(function* () { - const resolver = yield* ProjectFaviconResolver; - const cwd = yield* makeTempDir; - yield* writeTextFile(cwd, "favicon.svg", "favicon"); - - const resolved = yield* resolver.resolvePath(cwd); - - expect(resolved).not.toBeNull(); - expect(resolved).toContain("favicon.svg"); - }), - ); - - it.effect("resolves icon hrefs from project source files", () => - Effect.gen(function* () { - const resolver = yield* ProjectFaviconResolver; - const cwd = yield* makeTempDir; - yield* writeTextFile(cwd, "index.html", ''); - yield* writeTextFile(cwd, "public/brand/logo.svg", "brand"); - - const resolved = yield* resolver.resolvePath(cwd); - - expect(resolved).not.toBeNull(); - expect(resolved).toContain("public/brand/logo.svg"); - }), - ); - - it.effect("returns null when no icon is present", () => - Effect.gen(function* () { - const resolver = yield* ProjectFaviconResolver; - const cwd = yield* makeTempDir; - - const resolved = yield* resolver.resolvePath(cwd); - - expect(resolved).toBeNull(); - }), - ); - }); -}); diff --git a/apps/server/src/project/Layers/ProjectFaviconResolver.ts b/apps/server/src/project/Layers/ProjectFaviconResolver.ts deleted file mode 100644 index a994d1a7e8c32d0d8bfb1db35b96f6b56699b636..0000000000000000000000000000000000000000 --- a/apps/server/src/project/Layers/ProjectFaviconResolver.ts +++ /dev/null @@ -1,149 +0,0 @@ -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"; - -// Well-known favicon paths checked in order. -const FAVICON_CANDIDATES = [ - "favicon.svg", - "favicon.ico", - "favicon.png", - "public/favicon.svg", - "public/favicon.ico", - "public/favicon.png", - "app/favicon.ico", - "app/favicon.png", - "app/icon.svg", - "app/icon.png", - "app/icon.ico", - "src/favicon.ico", - "src/favicon.svg", - "src/app/favicon.ico", - "src/app/icon.svg", - "src/app/icon.png", - "assets/icon.svg", - "assets/icon.png", - "assets/logo.svg", - "assets/logo.png", - ".idea/icon.svg", -] as const; - -// Files that may contain a or icon metadata declaration. -const ICON_SOURCE_FILES = [ - "index.html", - "public/index.html", - "app/routes/__root.tsx", - "src/routes/__root.tsx", - "app/root.tsx", - "src/root.tsx", - "src/index.html", -] as const; - -// Matches tags or object-like icon metadata where rel/href can appear in any order. -const LINK_ICON_HTML_RE = - /]*\brel=["'](?:icon|shortcut icon)["'])(?=[^>]*\bhref=["']([^"'?]+))[^>]*>/i; -const LINK_ICON_OBJ_RE = - /(?=[^}]*\brel\s*:\s*["'](?:icon|shortcut icon)["'])(?=[^}]*\bhref\s*:\s*["']([^"'?]+))[^}]*/i; - -function extractIconHref(source: string): string | null { - const htmlMatch = source.match(LINK_ICON_HTML_RE); - if (htmlMatch?.[1]) return htmlMatch[1]; - const objMatch = source.match(LINK_ICON_OBJ_RE); - if (objMatch?.[1]) return objMatch[1]; - return null; -} - -export const makeProjectFaviconResolver = Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const workspacePaths = yield* WorkspacePaths; - - const resolveIconHref = (href: string): string[] => { - const clean = href.replace(/^\//, ""); - return [path.join("public", clean), clean]; - }; - - const findExistingFile = Effect.fn("ProjectFaviconResolver.findExistingFile")(function* ( - projectCwd: string, - relativeCandidates: ReadonlyArray, - ): Effect.fn.Return { - for (const relativePath of relativeCandidates) { - const candidate = yield* workspacePaths - .resolveRelativePathWithinRoot({ - workspaceRoot: projectCwd, - relativePath, - }) - .pipe(Effect.orElseSucceed(() => null)); - if (!candidate) { - continue; - } - const stats = yield* fileSystem - .stat(candidate.absolutePath) - .pipe(Effect.orElseSucceed(() => null)); - if (stats?.type === "File") { - return candidate.absolutePath; - } - } - return null; - }); - - const resolvePath: ProjectFaviconResolverShape["resolvePath"] = Effect.fn( - "ProjectFaviconResolver.resolvePath", - )(function* (cwd: string): Effect.fn.Return { - const projectCwd = yield* workspacePaths - .normalizeWorkspaceRoot(cwd) - .pipe(Effect.orElseSucceed(() => null)); - if (!projectCwd) { - return null; - } - for (const candidate of FAVICON_CANDIDATES) { - const existing = yield* findExistingFile(projectCwd, [candidate]); - if (existing) { - return existing; - } - } - - for (const sourceFile of ICON_SOURCE_FILES) { - const sourcePath = yield* workspacePaths - .resolveRelativePathWithinRoot({ - workspaceRoot: projectCwd, - relativePath: sourceFile, - }) - .pipe(Effect.orElseSucceed(() => null)); - if (!sourcePath) { - continue; - } - const source = yield* fileSystem - .readFileString(sourcePath.absolutePath) - .pipe(Effect.orElseSucceed(() => null)); - if (!source) { - continue; - } - const href = extractIconHref(source); - if (!href) { - continue; - } - const existing = yield* findExistingFile(projectCwd, resolveIconHref(href)); - if (existing) { - return existing; - } - } - - return null; - }); - - return { - resolvePath, - } satisfies ProjectFaviconResolverShape; -}); - -export const ProjectFaviconResolverLive = Layer.effect( - ProjectFaviconResolver, - makeProjectFaviconResolver, -); 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 91d39a3c1eaccd23023fbaeb536468a43bc29ff9..0000000000000000000000000000000000000000 --- 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 3c8772641be01ef6f25c9e6f562b7bc39a6aa788..0000000000000000000000000000000000000000 --- 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/RepositoryIdentityResolver.test.ts b/apps/server/src/project/Layers/RepositoryIdentityResolver.test.ts deleted file mode 100644 index 1c985cd859226723d3f30742935a116965cf4c0c..0000000000000000000000000000000000000000 --- a/apps/server/src/project/Layers/RepositoryIdentityResolver.test.ts +++ /dev/null @@ -1,239 +0,0 @@ -import * as NodeServices from "@effect/platform-node/NodeServices"; -import { expect, it } from "@effect/vitest"; -import * as Duration from "effect/Duration"; -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 { TestClock } from "effect/testing"; - -import * as ProcessRunner from "../../processRunner.ts"; -import { RepositoryIdentityResolver } from "../Services/RepositoryIdentityResolver.ts"; -import { - makeRepositoryIdentityResolver, - RepositoryIdentityResolverLive, -} from "./RepositoryIdentityResolver.ts"; - -const normalizePathSeparators = (value: string) => value.replaceAll("\\", "/"); -const normalizeResolvedPath = (value: string) => normalizePathSeparators(value); - -const git = (cwd: string, args: ReadonlyArray) => - Effect.gen(function* () { - const processRunner = yield* ProcessRunner.ProcessRunner; - return yield* processRunner.run({ - command: "git", - args: ["-C", cwd, ...args], - }); - }).pipe(Effect.provide(ProcessRunner.layer)); - -const makeRepositoryIdentityResolverTestLayer = (options: { - readonly positiveCacheTtl?: Duration.Input; - readonly negativeCacheTtl?: Duration.Input; -}) => - Layer.effect( - RepositoryIdentityResolver, - makeRepositoryIdentityResolver({ - cacheCapacity: 16, - ...options, - }), - ).pipe(Layer.provide(ProcessRunner.layer)); - -it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => { - it.effect("normalizes equivalent GitHub remotes into a stable repository identity", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const cwd = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-repository-identity-test-", - }); - - yield* git(cwd, ["init"]); - yield* git(cwd, ["remote", "add", "origin", "git@github.com:T3Tools/t3code.git"]); - - const resolver = yield* RepositoryIdentityResolver; - const identity = yield* resolver.resolve(cwd); - const resolvedIdentityRoot = - identity?.rootPath === undefined ? "" : yield* fileSystem.realPath(identity.rootPath); - const resolvedCwd = yield* fileSystem.realPath(cwd); - - expect(identity).not.toBeNull(); - expect(identity?.canonicalKey).toBe("github.com/t3tools/t3code"); - expect(normalizeResolvedPath(resolvedIdentityRoot)).toBe(normalizeResolvedPath(resolvedCwd)); - expect(identity?.displayName).toBe("t3tools/t3code"); - expect(identity?.provider).toBe("github"); - expect(identity?.owner).toBe("t3tools"); - expect(identity?.name).toBe("t3code"); - }).pipe(Effect.provide(RepositoryIdentityResolverLive)), - ); - - it.effect("returns the git top-level root path when resolving from a nested workspace", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const repoRoot = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-repository-identity-nested-root-test-", - }); - const nestedWorkspace = path.join(repoRoot, "packages", "web"); - - yield* fileSystem.makeDirectory(nestedWorkspace, { recursive: true }); - yield* git(repoRoot, ["init"]); - yield* git(repoRoot, ["remote", "add", "origin", "git@github.com:T3Tools/t3code.git"]); - - const resolver = yield* RepositoryIdentityResolver; - const identity = yield* resolver.resolve(nestedWorkspace); - const resolvedIdentityRoot = - identity?.rootPath === undefined ? "" : yield* fileSystem.realPath(identity.rootPath); - const resolvedRepoRoot = yield* fileSystem.realPath(repoRoot); - - expect(identity).not.toBeNull(); - expect(identity?.canonicalKey).toBe("github.com/t3tools/t3code"); - expect(normalizeResolvedPath(resolvedIdentityRoot)).toBe( - normalizeResolvedPath(resolvedRepoRoot), - ); - }).pipe(Effect.provide(RepositoryIdentityResolverLive)), - ); - - it.effect("returns null for non-git folders and repos without remotes", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const nonGitDir = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-repository-identity-non-git-", - }); - const gitDir = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-repository-identity-no-remote-", - }); - - yield* git(gitDir, ["init"]); - - const resolver = yield* RepositoryIdentityResolver; - const nonGitIdentity = yield* resolver.resolve(nonGitDir); - const noRemoteIdentity = yield* resolver.resolve(gitDir); - - expect(nonGitIdentity).toBeNull(); - expect(noRemoteIdentity).toBeNull(); - }).pipe(Effect.provide(RepositoryIdentityResolverLive)), - ); - - it.effect("prefers upstream over origin when both remotes are configured", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const cwd = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-repository-identity-upstream-test-", - }); - - yield* git(cwd, ["init"]); - 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 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)), - ); - - it.effect("uses the last remote path segment as the repository name for nested groups", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const cwd = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-repository-identity-nested-group-test-", - }); - - yield* git(cwd, ["init"]); - yield* git(cwd, ["remote", "add", "origin", "git@gitlab.com:T3Tools/platform/t3code.git"]); - - const resolver = yield* RepositoryIdentityResolver; - const identity = yield* resolver.resolve(cwd); - - expect(identity).not.toBeNull(); - expect(identity?.canonicalKey).toBe("gitlab.com/t3tools/platform/t3code"); - expect(identity?.displayName).toBe("t3tools/platform/t3code"); - expect(identity?.owner).toBe("t3tools"); - expect(identity?.name).toBe("t3code"); - }).pipe(Effect.provide(RepositoryIdentityResolverLive)), - ); - - it.effect( - "keeps null identities cached across repeated resolves until the negative TTL expires", - () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const cwd = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-repository-identity-late-remote-test-", - }); - - yield* git(cwd, ["init"]); - - const resolver = yield* RepositoryIdentityResolver; - const initialIdentity = yield* resolver.resolve(cwd); - expect(initialIdentity).toBeNull(); - - yield* git(cwd, ["remote", "add", "origin", "git@github.com:T3Tools/t3code.git"]); - - for (const _attempt of [1, 2, 3]) { - const cachedIdentity = yield* resolver.resolve(cwd); - expect(cachedIdentity).toBeNull(); - } - - yield* TestClock.adjust(Duration.millis(120)); - - const refreshedIdentity = yield* resolver.resolve(cwd); - expect(refreshedIdentity).not.toBeNull(); - expect(refreshedIdentity?.canonicalKey).toBe("github.com/t3tools/t3code"); - expect(refreshedIdentity?.name).toBe("t3code"); - }).pipe( - Effect.provide( - Layer.merge( - TestClock.layer(), - makeRepositoryIdentityResolverTestLayer({ - negativeCacheTtl: Duration.millis(50), - positiveCacheTtl: Duration.seconds(1), - }), - ), - ), - ), - ); - - it.effect("refreshes cached identities after the positive TTL when a remote changes", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const cwd = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-repository-identity-remote-change-test-", - }); - - yield* git(cwd, ["init"]); - yield* git(cwd, ["remote", "add", "origin", "git@github.com:T3Tools/t3code.git"]); - - const resolver = yield* RepositoryIdentityResolver; - const initialIdentity = yield* resolver.resolve(cwd); - expect(initialIdentity).not.toBeNull(); - expect(initialIdentity?.canonicalKey).toBe("github.com/t3tools/t3code"); - - yield* git(cwd, ["remote", "set-url", "origin", "git@github.com:T3Tools/t3code-next.git"]); - - const cachedIdentity = yield* resolver.resolve(cwd); - expect(cachedIdentity).not.toBeNull(); - expect(cachedIdentity?.canonicalKey).toBe("github.com/t3tools/t3code"); - - yield* TestClock.adjust(Duration.millis(180)); - - const refreshedIdentity = yield* resolver.resolve(cwd); - expect(refreshedIdentity).not.toBeNull(); - expect(refreshedIdentity?.canonicalKey).toBe("github.com/t3tools/t3code-next"); - expect(refreshedIdentity?.displayName).toBe("t3tools/t3code-next"); - expect(refreshedIdentity?.name).toBe("t3code-next"); - }).pipe( - Effect.provide( - Layer.merge( - TestClock.layer(), - makeRepositoryIdentityResolverTestLayer({ - negativeCacheTtl: Duration.millis(50), - positiveCacheTtl: Duration.millis(100), - }), - ), - ), - ), - ); -}); diff --git a/apps/server/src/project/Layers/RepositoryIdentityResolver.ts b/apps/server/src/project/Layers/RepositoryIdentityResolver.ts deleted file mode 100644 index d4ae073b953bccb907702ab0075c23e7a5043b0b..0000000000000000000000000000000000000000 --- a/apps/server/src/project/Layers/RepositoryIdentityResolver.ts +++ /dev/null @@ -1,173 +0,0 @@ -import type { RepositoryIdentity } from "@t3tools/contracts"; -import * as Cache from "effect/Cache"; -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 { - RepositoryIdentityResolver, - type RepositoryIdentityResolverShape, -} from "../Services/RepositoryIdentityResolver.ts"; - -function parseRemoteFetchUrls(stdout: string): Map { - const remotes = new Map(); - for (const line of stdout.split("\n")) { - const trimmed = line.trim(); - if (trimmed.length === 0) continue; - const match = /^(\S+)\s+(\S+)\s+\((fetch|push)\)$/.exec(trimmed); - if (!match) continue; - const [, remoteName = "", remoteUrl = "", direction = ""] = match; - if (direction !== "fetch" || remoteName.length === 0 || remoteUrl.length === 0) { - continue; - } - remotes.set(remoteName, remoteUrl); - } - return remotes; -} - -function pickPrimaryRemote( - remotes: ReadonlyMap, -): { readonly remoteName: string; readonly remoteUrl: string } | null { - for (const preferredRemoteName of ["upstream", "origin"] as const) { - const remoteUrl = remotes.get(preferredRemoteName); - if (remoteUrl) { - return { remoteName: preferredRemoteName, remoteUrl }; - } - } - - const [remoteName, remoteUrl] = - [...remotes.entries()].toSorted(([left], [right]) => left.localeCompare(right))[0] ?? []; - return remoteName && remoteUrl ? { remoteName, remoteUrl } : null; -} - -function buildRepositoryIdentity(input: { - readonly remoteName: string; - readonly remoteUrl: string; - readonly rootPath: string; -}): RepositoryIdentity { - const canonicalKey = normalizeGitRemoteUrl(input.remoteUrl); - const sourceControlProvider = detectSourceControlProviderFromGitRemoteUrl(input.remoteUrl); - const repositoryPath = canonicalKey.split("/").slice(1).join("/"); - const repositoryPathSegments = repositoryPath.split("/").filter((segment) => segment.length > 0); - const [owner] = repositoryPathSegments; - const repositoryName = repositoryPathSegments.at(-1); - - return { - canonicalKey, - locator: { - source: "git-remote", - remoteName: input.remoteName, - remoteUrl: input.remoteUrl, - }, - rootPath: input.rootPath, - ...(repositoryPath ? { displayName: repositoryPath } : {}), - ...(sourceControlProvider ? { provider: sourceControlProvider.kind } : {}), - ...(owner ? { owner } : {}), - ...(repositoryName ? { name: repositoryName } : {}), - }; -} - -const DEFAULT_REPOSITORY_IDENTITY_CACHE_CAPACITY = 512; -const DEFAULT_POSITIVE_CACHE_TTL = Duration.minutes(1); -const DEFAULT_NEGATIVE_CACHE_TTL = Duration.minutes(1); - -interface RepositoryIdentityResolverOptions { - readonly cacheCapacity?: number; - readonly positiveCacheTtl?: Duration.Input; - readonly negativeCacheTtl?: Duration.Input; -} - -const resolveRepositoryIdentityCacheKey = Effect.fn("resolveRepositoryIdentityCacheKey")(function* ( - cwd: string, -) { - const processRunner = yield* ProcessRunner.ProcessRunner; - let cacheKey = cwd; - - // 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 candidate = topLevelResult.value.stdout.trim(); - if (candidate.length > 0) { - cacheKey = candidate; - } - - return cacheKey; -}); - -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 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 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 { - resolve, - } satisfies RepositoryIdentityResolverShape; - }, -); - -export const RepositoryIdentityResolverLive = Layer.effect( - RepositoryIdentityResolver, - makeRepositoryIdentityResolver(), -).pipe(Layer.provide(ProcessRunner.layer)); diff --git a/apps/server/src/project/ProjectFaviconResolver.test.ts b/apps/server/src/project/ProjectFaviconResolver.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..37bda11e6aabbde21931027768626e09b1f053d5 --- /dev/null +++ b/apps/server/src/project/ProjectFaviconResolver.test.ts @@ -0,0 +1,77 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { it, describe, expect } from "@effect/vitest"; +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 WorkspacePaths from "../workspace/WorkspacePaths.ts"; +import * as ProjectFaviconResolver from "./ProjectFaviconResolver.ts"; + +const TestLayer = Layer.empty.pipe( + Layer.provideMerge(ProjectFaviconResolver.layer.pipe(Layer.provide(WorkspacePaths.layer))), + Layer.provideMerge(NodeServices.layer), +); + +const makeTempDir = Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + return yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3code-project-favicon-", + }); +}); + +const writeTextFile = Effect.fn("writeTextFile")(function* ( + cwd: string, + relativePath: string, + contents: string, +) { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const absolutePath = path.join(cwd, relativePath); + yield* fileSystem + .makeDirectory(path.dirname(absolutePath), { recursive: true }) + .pipe(Effect.orDie); + yield* fileSystem.writeFileString(absolutePath, contents).pipe(Effect.orDie); +}); + +it.layer(TestLayer)("ProjectFaviconResolverLive", (it) => { + describe("resolvePath", () => { + it.effect("prefers well-known favicon files", () => + Effect.gen(function* () { + const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver; + const cwd = yield* makeTempDir; + yield* writeTextFile(cwd, "favicon.svg", "favicon"); + + const resolved = yield* resolver.resolvePath(cwd); + + expect(resolved).not.toBeNull(); + expect(resolved).toContain("favicon.svg"); + }), + ); + + it.effect("resolves icon hrefs from project source files", () => + Effect.gen(function* () { + const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver; + const cwd = yield* makeTempDir; + yield* writeTextFile(cwd, "index.html", ''); + yield* writeTextFile(cwd, "public/brand/logo.svg", "brand"); + + const resolved = yield* resolver.resolvePath(cwd); + + expect(resolved).not.toBeNull(); + expect(resolved).toContain("public/brand/logo.svg"); + }), + ); + + it.effect("returns null when no icon is present", () => + Effect.gen(function* () { + const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver; + const cwd = yield* makeTempDir; + + const resolved = yield* resolver.resolvePath(cwd); + + expect(resolved).toBeNull(); + }), + ); + }); +}); diff --git a/apps/server/src/project/ProjectFaviconResolver.ts b/apps/server/src/project/ProjectFaviconResolver.ts new file mode 100644 index 0000000000000000000000000000000000000000..4c685a20f88ca6c2ea115b0e26ef815e378a2001 --- /dev/null +++ b/apps/server/src/project/ProjectFaviconResolver.ts @@ -0,0 +1,162 @@ +/** + * 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 * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; + +// Well-known favicon paths checked in order. +const FAVICON_CANDIDATES = [ + "favicon.svg", + "favicon.ico", + "favicon.png", + "public/favicon.svg", + "public/favicon.ico", + "public/favicon.png", + "app/favicon.ico", + "app/favicon.png", + "app/icon.svg", + "app/icon.png", + "app/icon.ico", + "src/favicon.ico", + "src/favicon.svg", + "src/app/favicon.ico", + "src/app/icon.svg", + "src/app/icon.png", + "assets/icon.svg", + "assets/icon.png", + "assets/logo.svg", + "assets/logo.png", + ".idea/icon.svg", +] as const; + +// Files that may contain a or icon metadata declaration. +const ICON_SOURCE_FILES = [ + "index.html", + "public/index.html", + "app/routes/__root.tsx", + "src/routes/__root.tsx", + "app/root.tsx", + "src/root.tsx", + "src/index.html", +] as const; + +// Matches tags or object-like icon metadata where rel/href can appear in any order. +const LINK_ICON_HTML_RE = + /]*\brel=["'](?:icon|shortcut icon)["'])(?=[^>]*\bhref=["']([^"'?]+))[^>]*>/i; +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]; + const objMatch = source.match(LINK_ICON_OBJ_RE); + if (objMatch?.[1]) return objMatch[1]; + return null; +} + +export const make = Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const workspacePaths = yield* WorkspacePaths.WorkspacePaths; + + const resolveIconHref = (href: string): ReadonlyArray => { + const clean = href.replace(/^\//, ""); + return [path.join("public", clean), clean]; + }; + + const findExistingFile = Effect.fn("ProjectFaviconResolver.findExistingFile")(function* ( + projectCwd: string, + relativeCandidates: ReadonlyArray, + ): Effect.fn.Return { + for (const relativePath of relativeCandidates) { + const candidate = yield* workspacePaths + .resolveRelativePathWithinRoot({ + workspaceRoot: projectCwd, + relativePath, + }) + .pipe(Effect.orElseSucceed(() => null)); + if (!candidate) { + continue; + } + const stats = yield* fileSystem + .stat(candidate.absolutePath) + .pipe(Effect.orElseSucceed(() => null)); + if (stats?.type === "File") { + return candidate.absolutePath; + } + } + return null; + }); + + const resolvePath: ProjectFaviconResolver["Service"]["resolvePath"] = Effect.fn( + "ProjectFaviconResolver.resolvePath", + )(function* (cwd) { + const projectCwd = yield* workspacePaths + .normalizeWorkspaceRoot(cwd) + .pipe(Effect.orElseSucceed(() => null)); + if (!projectCwd) { + return null; + } + for (const candidate of FAVICON_CANDIDATES) { + const existing = yield* findExistingFile(projectCwd, [candidate]); + if (existing) { + return existing; + } + } + + for (const sourceFile of ICON_SOURCE_FILES) { + const sourcePath = yield* workspacePaths + .resolveRelativePathWithinRoot({ + workspaceRoot: projectCwd, + relativePath: sourceFile, + }) + .pipe(Effect.orElseSucceed(() => null)); + if (!sourcePath) { + continue; + } + const source = yield* fileSystem + .readFileString(sourcePath.absolutePath) + .pipe(Effect.orElseSucceed(() => null)); + if (!source) { + continue; + } + const href = extractIconHref(source); + if (!href) { + continue; + } + const existing = yield* findExistingFile(projectCwd, resolveIconHref(href)); + if (existing) { + return existing; + } + } + + return null; + }); + + return ProjectFaviconResolver.of({ resolvePath }); +}); + +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 0000000000000000000000000000000000000000..d7a1bd15c58cd313c410aa792504df6e50403087 --- /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 0000000000000000000000000000000000000000..575400881284225a26626399739900e0d2641992 --- /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/RepositoryIdentityResolver.test.ts b/apps/server/src/project/RepositoryIdentityResolver.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..a997459e63d7ebe5a8a1a7fbf29eb3755d589371 --- /dev/null +++ b/apps/server/src/project/RepositoryIdentityResolver.test.ts @@ -0,0 +1,235 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { expect, it } from "@effect/vitest"; +import * as Duration from "effect/Duration"; +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 { TestClock } from "effect/testing"; + +import * as ProcessRunner from "../processRunner.ts"; +import * as RepositoryIdentityResolver from "./RepositoryIdentityResolver.ts"; + +const normalizePathSeparators = (value: string) => value.replaceAll("\\", "/"); +const normalizeResolvedPath = (value: string) => normalizePathSeparators(value); + +const git = (cwd: string, args: ReadonlyArray) => + Effect.gen(function* () { + const processRunner = yield* ProcessRunner.ProcessRunner; + return yield* processRunner.run({ + command: "git", + args: ["-C", cwd, ...args], + }); + }).pipe(Effect.provide(ProcessRunner.layer)); + +const makeRepositoryIdentityResolverTestLayer = (options: { + readonly positiveCacheTtl?: Duration.Input; + readonly negativeCacheTtl?: Duration.Input; +}) => + Layer.effect( + RepositoryIdentityResolver.RepositoryIdentityResolver, + RepositoryIdentityResolver.make({ + cacheCapacity: 16, + ...options, + }), + ).pipe(Layer.provide(ProcessRunner.layer)); + +it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => { + it.effect("normalizes equivalent GitHub remotes into a stable repository identity", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const cwd = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-repository-identity-test-", + }); + + yield* git(cwd, ["init"]); + yield* git(cwd, ["remote", "add", "origin", "git@github.com:T3Tools/t3code.git"]); + + const resolver = yield* RepositoryIdentityResolver.RepositoryIdentityResolver; + const identity = yield* resolver.resolve(cwd); + const resolvedIdentityRoot = + identity?.rootPath === undefined ? "" : yield* fileSystem.realPath(identity.rootPath); + const resolvedCwd = yield* fileSystem.realPath(cwd); + + expect(identity).not.toBeNull(); + expect(identity?.canonicalKey).toBe("github.com/t3tools/t3code"); + expect(normalizeResolvedPath(resolvedIdentityRoot)).toBe(normalizeResolvedPath(resolvedCwd)); + expect(identity?.displayName).toBe("t3tools/t3code"); + expect(identity?.provider).toBe("github"); + expect(identity?.owner).toBe("t3tools"); + expect(identity?.name).toBe("t3code"); + }).pipe(Effect.provide(RepositoryIdentityResolver.layer)), + ); + + it.effect("returns the git top-level root path when resolving from a nested workspace", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const repoRoot = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-repository-identity-nested-root-test-", + }); + const nestedWorkspace = path.join(repoRoot, "packages", "web"); + + yield* fileSystem.makeDirectory(nestedWorkspace, { recursive: true }); + yield* git(repoRoot, ["init"]); + yield* git(repoRoot, ["remote", "add", "origin", "git@github.com:T3Tools/t3code.git"]); + + const resolver = yield* RepositoryIdentityResolver.RepositoryIdentityResolver; + const identity = yield* resolver.resolve(nestedWorkspace); + const resolvedIdentityRoot = + identity?.rootPath === undefined ? "" : yield* fileSystem.realPath(identity.rootPath); + const resolvedRepoRoot = yield* fileSystem.realPath(repoRoot); + + expect(identity).not.toBeNull(); + expect(identity?.canonicalKey).toBe("github.com/t3tools/t3code"); + expect(normalizeResolvedPath(resolvedIdentityRoot)).toBe( + normalizeResolvedPath(resolvedRepoRoot), + ); + }).pipe(Effect.provide(RepositoryIdentityResolver.layer)), + ); + + it.effect("returns null for non-git folders and repos without remotes", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const nonGitDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-repository-identity-non-git-", + }); + const gitDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-repository-identity-no-remote-", + }); + + yield* git(gitDir, ["init"]); + + 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(RepositoryIdentityResolver.layer)), + ); + + it.effect("prefers upstream over origin when both remotes are configured", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const cwd = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-repository-identity-upstream-test-", + }); + + yield* git(cwd, ["init"]); + 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.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(RepositoryIdentityResolver.layer)), + ); + + it.effect("uses the last remote path segment as the repository name for nested groups", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const cwd = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-repository-identity-nested-group-test-", + }); + + yield* git(cwd, ["init"]); + yield* git(cwd, ["remote", "add", "origin", "git@gitlab.com:T3Tools/platform/t3code.git"]); + + const resolver = yield* RepositoryIdentityResolver.RepositoryIdentityResolver; + const identity = yield* resolver.resolve(cwd); + + expect(identity).not.toBeNull(); + expect(identity?.canonicalKey).toBe("gitlab.com/t3tools/platform/t3code"); + expect(identity?.displayName).toBe("t3tools/platform/t3code"); + expect(identity?.owner).toBe("t3tools"); + expect(identity?.name).toBe("t3code"); + }).pipe(Effect.provide(RepositoryIdentityResolver.layer)), + ); + + it.effect( + "keeps null identities cached across repeated resolves until the negative TTL expires", + () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const cwd = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-repository-identity-late-remote-test-", + }); + + yield* git(cwd, ["init"]); + + const resolver = yield* RepositoryIdentityResolver.RepositoryIdentityResolver; + const initialIdentity = yield* resolver.resolve(cwd); + expect(initialIdentity).toBeNull(); + + yield* git(cwd, ["remote", "add", "origin", "git@github.com:T3Tools/t3code.git"]); + + for (const _attempt of [1, 2, 3]) { + const cachedIdentity = yield* resolver.resolve(cwd); + expect(cachedIdentity).toBeNull(); + } + + yield* TestClock.adjust(Duration.millis(120)); + + const refreshedIdentity = yield* resolver.resolve(cwd); + expect(refreshedIdentity).not.toBeNull(); + expect(refreshedIdentity?.canonicalKey).toBe("github.com/t3tools/t3code"); + expect(refreshedIdentity?.name).toBe("t3code"); + }).pipe( + Effect.provide( + Layer.merge( + TestClock.layer(), + makeRepositoryIdentityResolverTestLayer({ + negativeCacheTtl: Duration.millis(50), + positiveCacheTtl: Duration.seconds(1), + }), + ), + ), + ), + ); + + it.effect("refreshes cached identities after the positive TTL when a remote changes", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const cwd = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-repository-identity-remote-change-test-", + }); + + yield* git(cwd, ["init"]); + yield* git(cwd, ["remote", "add", "origin", "git@github.com:T3Tools/t3code.git"]); + + const resolver = yield* RepositoryIdentityResolver.RepositoryIdentityResolver; + const initialIdentity = yield* resolver.resolve(cwd); + expect(initialIdentity).not.toBeNull(); + expect(initialIdentity?.canonicalKey).toBe("github.com/t3tools/t3code"); + + yield* git(cwd, ["remote", "set-url", "origin", "git@github.com:T3Tools/t3code-next.git"]); + + const cachedIdentity = yield* resolver.resolve(cwd); + expect(cachedIdentity).not.toBeNull(); + expect(cachedIdentity?.canonicalKey).toBe("github.com/t3tools/t3code"); + + yield* TestClock.adjust(Duration.millis(180)); + + const refreshedIdentity = yield* resolver.resolve(cwd); + expect(refreshedIdentity).not.toBeNull(); + expect(refreshedIdentity?.canonicalKey).toBe("github.com/t3tools/t3code-next"); + expect(refreshedIdentity?.displayName).toBe("t3tools/t3code-next"); + expect(refreshedIdentity?.name).toBe("t3code-next"); + }).pipe( + Effect.provide( + Layer.merge( + TestClock.layer(), + makeRepositoryIdentityResolverTestLayer({ + negativeCacheTtl: Duration.millis(50), + positiveCacheTtl: Duration.millis(100), + }), + ), + ), + ), + ); +}); diff --git a/apps/server/src/project/RepositoryIdentityResolver.ts b/apps/server/src/project/RepositoryIdentityResolver.ts new file mode 100644 index 0000000000000000000000000000000000000000..50608e7704c7f8a5939403f7526416c4ca87b739 --- /dev/null +++ b/apps/server/src/project/RepositoryIdentityResolver.ts @@ -0,0 +1,174 @@ +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 * 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, + { + readonly resolve: (cwd: string) => Effect.Effect; + } +>()("t3/project/RepositoryIdentityResolver") {} + +function parseRemoteFetchUrls(stdout: string): Map { + const remotes = new Map(); + for (const line of stdout.split("\n")) { + const trimmed = line.trim(); + if (trimmed.length === 0) continue; + const match = /^(\S+)\s+(\S+)\s+\((fetch|push)\)$/.exec(trimmed); + if (!match) continue; + const [, remoteName = "", remoteUrl = "", direction = ""] = match; + if (direction !== "fetch" || remoteName.length === 0 || remoteUrl.length === 0) { + continue; + } + remotes.set(remoteName, remoteUrl); + } + return remotes; +} + +function pickPrimaryRemote( + remotes: ReadonlyMap, +): { readonly remoteName: string; readonly remoteUrl: string } | null { + for (const preferredRemoteName of ["upstream", "origin"] as const) { + const remoteUrl = remotes.get(preferredRemoteName); + if (remoteUrl) { + return { remoteName: preferredRemoteName, remoteUrl }; + } + } + + const [remoteName, remoteUrl] = + [...remotes.entries()].toSorted(([left], [right]) => left.localeCompare(right))[0] ?? []; + return remoteName && remoteUrl ? { remoteName, remoteUrl } : null; +} + +function buildRepositoryIdentity(input: { + readonly remoteName: string; + readonly remoteUrl: string; + readonly rootPath: string; +}): RepositoryIdentity { + const canonicalKey = normalizeGitRemoteUrl(input.remoteUrl); + const sourceControlProvider = detectSourceControlProviderFromGitRemoteUrl(input.remoteUrl); + const repositoryPath = canonicalKey.split("/").slice(1).join("/"); + const repositoryPathSegments = repositoryPath.split("/").filter((segment) => segment.length > 0); + const [owner] = repositoryPathSegments; + const repositoryName = repositoryPathSegments.at(-1); + + return { + canonicalKey, + locator: { + source: "git-remote", + remoteName: input.remoteName, + remoteUrl: input.remoteUrl, + }, + rootPath: input.rootPath, + ...(repositoryPath ? { displayName: repositoryPath } : {}), + ...(sourceControlProvider ? { provider: sourceControlProvider.kind } : {}), + ...(owner ? { owner } : {}), + ...(repositoryName ? { name: repositoryName } : {}), + }; +} + +const resolveRepositoryIdentityCacheKey = Effect.fn("RepositoryIdentityResolver.resolveCacheKey")( + function* (cwd: string) { + const processRunner = yield* ProcessRunner.ProcessRunner; + let cacheKey = cwd; + + // 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 candidate = topLevelResult.value.stdout.trim(); + if (candidate.length > 0) { + cacheKey = candidate; + } + + return cacheKey; + }, +); + +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", 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; + + 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); + }); + + return RepositoryIdentityResolver.of({ resolve }); +}); + +export const layer = Layer.effect(RepositoryIdentityResolver, make()).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 ad1b466e2c7f486a6e48b6bd29082b9495ab697e..0000000000000000000000000000000000000000 --- 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 17168eda7f1b6c84b3ea74498f5d97c18f5a9fea..0000000000000000000000000000000000000000 --- 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 ef0b128c6f79debef1703f3a39c540f56d339c57..0000000000000000000000000000000000000000 --- 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 4d31bb26137cd797507248885f47c7c2966bdadc..eb2b2c3f2fa95a70b6913abeef1ac2127c89f873 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 8528b4b0c8ed62a0903137f2500492dbd8c85675..4e036e3ea0e984b431e5f7639bc5d5f4d7ec4ecb 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 76824af73e3f1205bd8b461756af617c90652fb0..fd69c610df4b3fcd038a7c7e50f1f53d11be6a55 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 987ba83deae887efe35234aa04ac940120e15835..81d0013b20caa8e555c570c0961a651a74781b32 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 2109f4c545825a74025c3fded40d034d670d0b73..e331f0cd4d6cce2ae6919c25d2264c311b51ad91 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 35ac5a06fc9b65de74cb33b125fcfdeef0726f5e..cbdf58c4d6769da02cd5291db5b0ca1639cbf5b1 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.test.ts b/apps/server/src/workspace/Layers/WorkspaceFileSystem.test.ts deleted file mode 100644 index 5a4ec54686e3377b54f1a84c44ab15d6aef86c70..0000000000000000000000000000000000000000 --- a/apps/server/src/workspace/Layers/WorkspaceFileSystem.test.ts +++ /dev/null @@ -1,188 +0,0 @@ -import * as NodeServices from "@effect/platform-node/NodeServices"; -import { it, describe, expect } from "@effect/vitest"; -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 * 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))), -); - -const TestLayer = Layer.empty.pipe( - Layer.provideMerge(ProjectLayer), - Layer.provideMerge(WorkspaceEntries.layer.pipe(Layer.provide(WorkspacePathsLive))), - Layer.provideMerge(WorkspacePathsLive), - Layer.provideMerge(VcsDriverRegistry.layer.pipe(Layer.provide(VcsProcess.layer))), - Layer.provide( - ServerConfig.layerTest(process.cwd(), { - prefix: "t3-workspace-files-test-", - }), - ), - Layer.provideMerge(NodeServices.layer), -); - -const makeTempDir = Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - return yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3code-workspace-files-", - }); -}); - -const writeTextFile = Effect.fn("writeTextFile")(function* ( - cwd: string, - relativePath: string, - contents = "", -) { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const absolutePath = path.join(cwd, relativePath); - yield* fileSystem - .makeDirectory(path.dirname(absolutePath), { recursive: true }) - .pipe(Effect.orDie); - yield* fileSystem.writeFileString(absolutePath, contents).pipe(Effect.orDie); -}); - -it.layer(TestLayer, { excludeTestServices: true })("WorkspaceFileSystemLive", (it) => { - describe("readFile", () => { - it.effect("reads UTF-8 files relative to the workspace root", () => - Effect.gen(function* () { - const workspaceFileSystem = yield* WorkspaceFileSystem; - const cwd = yield* makeTempDir; - yield* writeTextFile(cwd, "src/index.ts", "export const answer = 42;\n"); - - const result = yield* workspaceFileSystem.readFile({ - cwd, - relativePath: "src/index.ts", - }); - - expect(result).toEqual({ - relativePath: "src/index.ts", - contents: "export const answer = 42;\n", - byteLength: 26, - truncated: false, - }); - }), - ); - - it.effect("rejects reads outside the workspace root", () => - Effect.gen(function* () { - const workspaceFileSystem = yield* WorkspaceFileSystem; - const cwd = yield* makeTempDir; - - const error = yield* workspaceFileSystem - .readFile({ cwd, relativePath: "../escape.md" }) - .pipe(Effect.flip); - - expect(error.message).toContain( - "Workspace file path must be relative to the project root: ../escape.md", - ); - }), - ); - - it.effect("rejects symlinks that resolve outside the workspace root", () => - Effect.gen(function* () { - const workspaceFileSystem = yield* WorkspaceFileSystem; - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const cwd = yield* makeTempDir; - const outsideDir = yield* makeTempDir; - yield* writeTextFile(outsideDir, "secret.txt", "outside\n"); - yield* fileSystem.symlink( - path.join(outsideDir, "secret.txt"), - path.join(cwd, "linked-secret.txt"), - ); - - const error = yield* workspaceFileSystem - .readFile({ cwd, relativePath: "linked-secret.txt" }) - .pipe(Effect.flip); - - expect(error.message).toContain("resolves outside the project root"); - }), - ); - }); - - describe("writeFile", () => { - it.effect("writes files relative to the workspace root", () => - Effect.gen(function* () { - const workspaceFileSystem = yield* WorkspaceFileSystem; - const cwd = yield* makeTempDir; - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const result = yield* workspaceFileSystem.writeFile({ - cwd, - relativePath: "plans/effect-rpc.md", - contents: "# Plan\n", - }); - const saved = yield* fileSystem - .readFileString(path.join(cwd, "plans/effect-rpc.md")) - .pipe(Effect.orDie); - - expect(result).toEqual({ relativePath: "plans/effect-rpc.md" }); - expect(saved).toBe("# Plan\n"); - }), - ); - - it.effect("invalidates workspace entry search cache after writes", () => - Effect.gen(function* () { - const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; - const workspaceFileSystem = yield* WorkspaceFileSystem; - const cwd = yield* makeTempDir; - yield* writeTextFile(cwd, "src/existing.ts", "export {};\n"); - - const beforeWrite = yield* workspaceEntries.list({ cwd }); - expect(beforeWrite.entries.some((entry) => entry.path === "plans/effect-rpc.md")).toBe( - false, - ); - - yield* workspaceFileSystem.writeFile({ - cwd, - relativePath: "plans/effect-rpc.md", - contents: "# Plan\n", - }); - - const afterWrite = yield* workspaceEntries.list({ cwd }); - expect(afterWrite.entries).toEqual( - expect.arrayContaining([expect.objectContaining({ path: "plans/effect-rpc.md" })]), - ); - expect(afterWrite.truncated).toBe(false); - }), - ); - - it.effect("rejects writes outside the workspace root", () => - Effect.gen(function* () { - const workspaceFileSystem = yield* WorkspaceFileSystem; - const cwd = yield* makeTempDir; - const path = yield* Path.Path; - const fileSystem = yield* FileSystem.FileSystem; - - const error = yield* workspaceFileSystem - .writeFile({ - cwd, - relativePath: "../escape.md", - contents: "# nope\n", - }) - .pipe(Effect.flip); - - expect(error.message).toContain( - "Workspace file path must be relative to the project root: ../escape.md", - ); - - const escapedPath = path.resolve(cwd, "..", "escape.md"); - const escapedStat = yield* fileSystem - .stat(escapedPath) - .pipe(Effect.orElseSucceed(() => null)); - expect(escapedStat).toBeNull(); - }), - ); - }); -}); diff --git a/apps/server/src/workspace/Layers/WorkspaceFileSystem.ts b/apps/server/src/workspace/Layers/WorkspaceFileSystem.ts deleted file mode 100644 index 61056042bf3aede7caa8f6826b7b0c4c9d231507..0000000000000000000000000000000000000000 --- 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.test.ts b/apps/server/src/workspace/Layers/WorkspacePaths.test.ts deleted file mode 100644 index 0a9252a7def20ba7d0dd48795db98a81b3e59d98..0000000000000000000000000000000000000000 --- a/apps/server/src/workspace/Layers/WorkspacePaths.test.ts +++ /dev/null @@ -1,134 +0,0 @@ -import * as NodeServices from "@effect/platform-node/NodeServices"; -import { it, describe, expect } from "@effect/vitest"; -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 } from "../Services/WorkspacePaths.ts"; -import { WorkspacePathsLive } from "./WorkspacePaths.ts"; - -const TestLayer = Layer.empty.pipe( - Layer.provideMerge(WorkspacePathsLive), - Layer.provideMerge(NodeServices.layer), -); - -const makeTempDir = Effect.fn("makeTempDir")(function* () { - const fileSystem = yield* FileSystem.FileSystem; - return yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3code-project-paths-", - }); -}); - -const writeTextFile = Effect.fn("writeTextFile")(function* ( - cwd: string, - relativePath: string, - contents = "", -) { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const absolutePath = path.join(cwd, relativePath); - yield* fileSystem - .makeDirectory(path.dirname(absolutePath), { recursive: true }) - .pipe(Effect.orDie); - yield* fileSystem.writeFileString(absolutePath, contents).pipe(Effect.orDie); -}); - -it.layer(TestLayer)("WorkspacePathsLive", (it) => { - describe("normalizeWorkspaceRoot", () => { - it.effect("resolves an existing directory", () => - Effect.gen(function* () { - const workspacePaths = yield* WorkspacePaths; - const cwd = yield* makeTempDir(); - - const resolved = yield* workspacePaths.normalizeWorkspaceRoot(cwd); - - expect(resolved).toBe(cwd); - }), - ); - - it.effect("rejects missing directories", () => - Effect.gen(function* () { - const workspacePaths = yield* WorkspacePaths; - const cwd = yield* makeTempDir(); - const path = yield* Path.Path; - - const error = yield* workspacePaths - .normalizeWorkspaceRoot(path.join(cwd, "missing")) - .pipe(Effect.flip); - - expect(error.message).toContain("Workspace root does not exist:"); - }), - ); - - it.effect("creates missing directories when createIfMissing is enabled", () => - Effect.gen(function* () { - const workspacePaths = yield* WorkspacePaths; - const fileSystem = yield* FileSystem.FileSystem; - const cwd = yield* makeTempDir(); - const path = yield* Path.Path; - const missingPath = path.join(cwd, "nested", "new-project"); - - const resolved = yield* workspacePaths.normalizeWorkspaceRoot(missingPath, { - createIfMissing: true, - }); - const stat = yield* fileSystem.stat(resolved); - - expect(resolved).toBe(missingPath); - expect(stat.type).toBe("Directory"); - }), - ); - - it.effect("rejects file paths", () => - Effect.gen(function* () { - const workspacePaths = yield* WorkspacePaths; - const cwd = yield* makeTempDir(); - const path = yield* Path.Path; - const filePath = path.join(cwd, "README.md"); - yield* writeTextFile(cwd, "README.md", "# hi\n"); - - const error = yield* workspacePaths.normalizeWorkspaceRoot(filePath).pipe(Effect.flip); - - expect(error.message).toContain("Workspace root is not a directory:"); - }), - ); - }); - - describe("resolveRelativePathWithinRoot", () => { - it.effect("resolves relative paths inside the workspace root", () => - Effect.gen(function* () { - const workspacePaths = yield* WorkspacePaths; - const cwd = yield* makeTempDir(); - const path = yield* Path.Path; - - const resolved = yield* workspacePaths.resolveRelativePathWithinRoot({ - workspaceRoot: cwd, - relativePath: "plans/effect-rpc.md", - }); - - expect(resolved).toEqual({ - absolutePath: path.join(cwd, "plans/effect-rpc.md"), - relativePath: "plans/effect-rpc.md", - }); - }), - ); - - it.effect("rejects paths that escape the workspace root", () => - Effect.gen(function* () { - const workspacePaths = yield* WorkspacePaths; - const cwd = yield* makeTempDir(); - - const error = yield* workspacePaths - .resolveRelativePathWithinRoot({ - workspaceRoot: cwd, - relativePath: "../escape.md", - }) - .pipe(Effect.flip); - - expect(error.message).toContain( - "Workspace file path must be relative to the project root: ../escape.md", - ); - }), - ); - }); -}); diff --git a/apps/server/src/workspace/Layers/WorkspacePaths.ts b/apps/server/src/workspace/Layers/WorkspacePaths.ts deleted file mode 100644 index dfe02e8f67c46968203298934907a0494a82717f..0000000000000000000000000000000000000000 --- 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 5126ec417bf080ea26253a2cd21c1d332dbf046e..0000000000000000000000000000000000000000 --- 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 7c57ca19bd29b3de6a4394c85aaa9fc06f523be5..0000000000000000000000000000000000000000 --- 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 f8a518d8b33500fb85c73e06cad9b5e057ea34b3..7d6005f030d4c957b604441e60d7896cf46c1a64 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 bf9a51c74db2e2b30f9167efb65579c5091313da..aafd6ffd75a41c0266dc6c128d34a432ae29beca 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.", - }); - } +): 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 (!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.", - }); - } + if (!input.cwd) { + return yield* new WorkspaceEntriesCurrentProjectRequiredError({ + partialPath: input.partialPath, + }); + } + return path.resolve(expandHomePath(input.cwd, path), 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/WorkspaceFileSystem.test.ts b/apps/server/src/workspace/WorkspaceFileSystem.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..aa2dabb333763dd82cbcbcf1ae489f7c6e164d2a --- /dev/null +++ b/apps/server/src/workspace/WorkspaceFileSystem.test.ts @@ -0,0 +1,193 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { it, describe, expect } from "@effect/vitest"; +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 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(WorkspacePaths.layer))), + Layer.provideMerge(WorkspacePaths.layer), + Layer.provideMerge(VcsDriverRegistry.layer.pipe(Layer.provide(VcsProcess.layer))), + Layer.provide( + ServerConfig.ServerConfig.layerTest(process.cwd(), { + prefix: "t3-workspace-files-test-", + }), + ), + Layer.provideMerge(NodeServices.layer), +); + +const makeTempDir = Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + return yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3code-workspace-files-", + }); +}); + +const writeTextFile = Effect.fn("writeTextFile")(function* ( + cwd: string, + relativePath: string, + contents = "", +) { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const absolutePath = path.join(cwd, relativePath); + yield* fileSystem + .makeDirectory(path.dirname(absolutePath), { recursive: true }) + .pipe(Effect.orDie); + yield* fileSystem.writeFileString(absolutePath, contents).pipe(Effect.orDie); +}); + +it.layer(TestLayer, { excludeTestServices: true })("WorkspaceFileSystemLive", (it) => { + describe("readFile", () => { + it.effect("reads UTF-8 files relative to the workspace root", () => + Effect.gen(function* () { + const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem; + const cwd = yield* makeTempDir; + yield* writeTextFile(cwd, "src/index.ts", "export const answer = 42;\n"); + + const result = yield* workspaceFileSystem.readFile({ + cwd, + relativePath: "src/index.ts", + }); + + expect(result).toEqual({ + relativePath: "src/index.ts", + contents: "export const answer = 42;\n", + byteLength: 26, + truncated: false, + }); + }), + ); + + it.effect("rejects reads outside the workspace root", () => + Effect.gen(function* () { + const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem; + const cwd = yield* makeTempDir; + + const error = yield* workspaceFileSystem + .readFile({ cwd, relativePath: "../escape.md" }) + .pipe(Effect.flip); + + expect(error.message).toContain( + "Workspace file path must be relative to the project root: ../escape.md", + ); + }), + ); + + it.effect("rejects symlinks that resolve outside the workspace root", () => + Effect.gen(function* () { + const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const cwd = yield* makeTempDir; + const outsideDir = yield* makeTempDir; + yield* writeTextFile(outsideDir, "secret.txt", "outside\n"); + yield* fileSystem.symlink( + path.join(outsideDir, "secret.txt"), + path.join(cwd, "linked-secret.txt"), + ); + + const error = yield* workspaceFileSystem + .readFile({ cwd, relativePath: "linked-secret.txt" }) + .pipe(Effect.flip); + + 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.", + ); + }), + ); + }); + + describe("writeFile", () => { + it.effect("writes files relative to the workspace root", () => + Effect.gen(function* () { + const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem; + const cwd = yield* makeTempDir; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const result = yield* workspaceFileSystem.writeFile({ + cwd, + relativePath: "plans/effect-rpc.md", + contents: "# Plan\n", + }); + const saved = yield* fileSystem + .readFileString(path.join(cwd, "plans/effect-rpc.md")) + .pipe(Effect.orDie); + + expect(result).toEqual({ relativePath: "plans/effect-rpc.md" }); + expect(saved).toBe("# Plan\n"); + }), + ); + + it.effect("invalidates workspace entry search cache after writes", () => + Effect.gen(function* () { + const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; + const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem; + const cwd = yield* makeTempDir; + yield* writeTextFile(cwd, "src/existing.ts", "export {};\n"); + + const beforeWrite = yield* workspaceEntries.list({ cwd }); + expect(beforeWrite.entries.some((entry) => entry.path === "plans/effect-rpc.md")).toBe( + false, + ); + + yield* workspaceFileSystem.writeFile({ + cwd, + relativePath: "plans/effect-rpc.md", + contents: "# Plan\n", + }); + + const afterWrite = yield* workspaceEntries.list({ cwd }); + expect(afterWrite.entries).toEqual( + expect.arrayContaining([expect.objectContaining({ path: "plans/effect-rpc.md" })]), + ); + expect(afterWrite.truncated).toBe(false); + }), + ); + + it.effect("rejects writes outside the workspace root", () => + Effect.gen(function* () { + const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem; + const cwd = yield* makeTempDir; + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + + const error = yield* workspaceFileSystem + .writeFile({ + cwd, + relativePath: "../escape.md", + contents: "# nope\n", + }) + .pipe(Effect.flip); + + expect(error.message).toContain( + "Workspace file path must be relative to the project root: ../escape.md", + ); + + const escapedPath = path.resolve(cwd, "..", "escape.md"); + const escapedStat = yield* fileSystem + .stat(escapedPath) + .pipe(Effect.orElseSucceed(() => null)); + expect(escapedStat).toBeNull(); + }), + ); + }); +}); diff --git a/apps/server/src/workspace/WorkspaceFileSystem.ts b/apps/server/src/workspace/WorkspaceFileSystem.ts new file mode 100644 index 0000000000000000000000000000000000000000..48e02c89cae86cf7cab36fc8e5f1e463dfd8c3b8 --- /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/WorkspacePaths.test.ts b/apps/server/src/workspace/WorkspacePaths.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..ecce54b67d6be08f6c6895bedfaf1c66172d0136 --- /dev/null +++ b/apps/server/src/workspace/WorkspacePaths.test.ts @@ -0,0 +1,133 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { it, describe, expect } from "@effect/vitest"; +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 WorkspacePaths from "./WorkspacePaths.ts"; + +const TestLayer = Layer.empty.pipe( + Layer.provideMerge(WorkspacePaths.layer), + Layer.provideMerge(NodeServices.layer), +); + +const makeTempDir = Effect.fn("makeTempDir")(function* () { + const fileSystem = yield* FileSystem.FileSystem; + return yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3code-project-paths-", + }); +}); + +const writeTextFile = Effect.fn("writeTextFile")(function* ( + cwd: string, + relativePath: string, + contents = "", +) { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const absolutePath = path.join(cwd, relativePath); + yield* fileSystem + .makeDirectory(path.dirname(absolutePath), { recursive: true }) + .pipe(Effect.orDie); + yield* fileSystem.writeFileString(absolutePath, contents).pipe(Effect.orDie); +}); + +it.layer(TestLayer)("WorkspacePathsLive", (it) => { + describe("normalizeWorkspaceRoot", () => { + it.effect("resolves an existing directory", () => + Effect.gen(function* () { + const workspacePaths = yield* WorkspacePaths.WorkspacePaths; + const cwd = yield* makeTempDir(); + + const resolved = yield* workspacePaths.normalizeWorkspaceRoot(cwd); + + expect(resolved).toBe(cwd); + }), + ); + + it.effect("rejects missing directories", () => + Effect.gen(function* () { + const workspacePaths = yield* WorkspacePaths.WorkspacePaths; + const cwd = yield* makeTempDir(); + const path = yield* Path.Path; + + const error = yield* workspacePaths + .normalizeWorkspaceRoot(path.join(cwd, "missing")) + .pipe(Effect.flip); + + expect(error.message).toContain("Workspace root does not exist:"); + }), + ); + + it.effect("creates missing directories when createIfMissing is enabled", () => + Effect.gen(function* () { + const workspacePaths = yield* WorkspacePaths.WorkspacePaths; + const fileSystem = yield* FileSystem.FileSystem; + const cwd = yield* makeTempDir(); + const path = yield* Path.Path; + const missingPath = path.join(cwd, "nested", "new-project"); + + const resolved = yield* workspacePaths.normalizeWorkspaceRoot(missingPath, { + createIfMissing: true, + }); + const stat = yield* fileSystem.stat(resolved); + + expect(resolved).toBe(missingPath); + expect(stat.type).toBe("Directory"); + }), + ); + + it.effect("rejects file paths", () => + Effect.gen(function* () { + const workspacePaths = yield* WorkspacePaths.WorkspacePaths; + const cwd = yield* makeTempDir(); + const path = yield* Path.Path; + const filePath = path.join(cwd, "README.md"); + yield* writeTextFile(cwd, "README.md", "# hi\n"); + + const error = yield* workspacePaths.normalizeWorkspaceRoot(filePath).pipe(Effect.flip); + + expect(error.message).toContain("Workspace root is not a directory:"); + }), + ); + }); + + describe("resolveRelativePathWithinRoot", () => { + it.effect("resolves relative paths inside the workspace root", () => + Effect.gen(function* () { + const workspacePaths = yield* WorkspacePaths.WorkspacePaths; + const cwd = yield* makeTempDir(); + const path = yield* Path.Path; + + const resolved = yield* workspacePaths.resolveRelativePathWithinRoot({ + workspaceRoot: cwd, + relativePath: "plans/effect-rpc.md", + }); + + expect(resolved).toEqual({ + absolutePath: path.join(cwd, "plans/effect-rpc.md"), + relativePath: "plans/effect-rpc.md", + }); + }), + ); + + it.effect("rejects paths that escape the workspace root", () => + Effect.gen(function* () { + const workspacePaths = yield* WorkspacePaths.WorkspacePaths; + const cwd = yield* makeTempDir(); + + const error = yield* workspacePaths + .resolveRelativePathWithinRoot({ + workspaceRoot: cwd, + relativePath: "../escape.md", + }) + .pipe(Effect.flip); + + expect(error.message).toContain( + "Workspace file path must be relative to the project root: ../escape.md", + ); + }), + ); + }); +}); diff --git a/apps/server/src/workspace/WorkspacePaths.ts b/apps/server/src/workspace/WorkspacePaths.ts new file mode 100644 index 0000000000000000000000000000000000000000..8b6b685524bb4a152c62ea18e10b6a6468c6d559 --- /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 4bee3cbc089cb0afc1128da6b993bb83e82e0239..fcacf3caf1321e6ad91c4f74bfc357248b253c09 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; - }); +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 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 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 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 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 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 }); - }), + 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 0b25b25f6f6edc0cd4fa7e1ff2bb840d9402ee45..935dd47cc853acde5bc74cbfb1c73702c40a4c2a 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, }), ),