| author | |
| committer | |
| log | b0a3a504481ab4d8f2da0343e5a4258c10d03c73 |
| tree | cdb551d95e0637cf1363696e910ae3e97a335d9c |
| parent | 79c57170c7fb9cc1844427aed93c5f297fb110b5 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: codex <codex@users.noreply.github.com>65 files changed, 2499 insertions(+), 2508 deletions(-)
apps/server/integration/OrchestrationEngineHarness.integration.ts+5-5| ... | @@ -46,7 +46,7 @@ import { | ... | @@ -46,7 +46,7 @@ import { |
| 46 | import { ProviderService } from "../src/provider/Services/ProviderService.ts"; | 46 | import { ProviderService } from "../src/provider/Services/ProviderService.ts"; |
| 47 | import { AnalyticsService } from "../src/telemetry/Services/AnalyticsService.ts"; | 47 | import { AnalyticsService } from "../src/telemetry/Services/AnalyticsService.ts"; |
| 48 | import { CheckpointReactorLive } from "../src/orchestration/Layers/CheckpointReactor.ts"; | 48 | import { CheckpointReactorLive } from "../src/orchestration/Layers/CheckpointReactor.ts"; |
| 49 | import { RepositoryIdentityResolverLive } from "../src/project/Layers/RepositoryIdentityResolver.ts"; | 49 | import * as RepositoryIdentityResolver from "../src/project/RepositoryIdentityResolver.ts"; |
| 50 | import { OrchestrationEngineLive } from "../src/orchestration/Layers/OrchestrationEngine.ts"; | 50 | import { OrchestrationEngineLive } from "../src/orchestration/Layers/OrchestrationEngine.ts"; |
| 51 | import { OrchestrationProjectionPipelineLive } from "../src/orchestration/Layers/ProjectionPipeline.ts"; | 51 | import { OrchestrationProjectionPipelineLive } from "../src/orchestration/Layers/ProjectionPipeline.ts"; |
| 52 | import { OrchestrationProjectionSnapshotQueryLive } from "../src/orchestration/Layers/ProjectionSnapshotQuery.ts"; | 52 | import { OrchestrationProjectionSnapshotQueryLive } from "../src/orchestration/Layers/ProjectionSnapshotQuery.ts"; |
| ... | @@ -72,7 +72,7 @@ import { | ... | @@ -72,7 +72,7 @@ import { |
| 72 | } from "./TestProviderAdapter.integration.ts"; | 72 | } from "./TestProviderAdapter.integration.ts"; |
| 73 | import { deriveServerPaths, ServerConfig } from "../src/config.ts"; | 73 | import { deriveServerPaths, ServerConfig } from "../src/config.ts"; |
| 74 | import * as WorkspaceEntries from "../src/workspace/WorkspaceEntries.ts"; | 74 | import * as WorkspaceEntries from "../src/workspace/WorkspaceEntries.ts"; |
| 75 | import { WorkspacePathsLive } from "../src/workspace/Layers/WorkspacePaths.ts"; | 75 | import * as WorkspacePaths from "../src/workspace/WorkspacePaths.ts"; |
| 76 | import * as VcsDriverRegistry from "../src/vcs/VcsDriverRegistry.ts"; | 76 | import * as VcsDriverRegistry from "../src/vcs/VcsDriverRegistry.ts"; |
| 77 | import { VcsStatusBroadcaster } from "../src/vcs/VcsStatusBroadcaster.ts"; | 77 | import { VcsStatusBroadcaster } from "../src/vcs/VcsStatusBroadcaster.ts"; |
| 78 | import { GitWorkflowService } from "../src/git/GitWorkflowService.ts"; | 78 | import { GitWorkflowService } from "../src/git/GitWorkflowService.ts"; |
| ... | @@ -348,12 +348,12 @@ export const makeOrchestrationIntegrationHarness = ( | ... | @@ -348,12 +348,12 @@ export const makeOrchestrationIntegrationHarness = ( |
| 348 | ), | 348 | ), |
| 349 | Layer.provideMerge( | 349 | Layer.provideMerge( |
| 350 | WorkspaceEntries.layer.pipe( | 350 | WorkspaceEntries.layer.pipe( |
| 351 | Layer.provide(WorkspacePathsLive), | 351 | Layer.provide(WorkspacePaths.layer), |
| 352 | Layer.provideMerge(VcsDriverRegistry.layer), | 352 | Layer.provideMerge(VcsDriverRegistry.layer), |
| 353 | Layer.provide(NodeServices.layer), | 353 | Layer.provide(NodeServices.layer), |
| 354 | ), | 354 | ), |
| 355 | ), | 355 | ), |
| 356 | Layer.provideMerge(WorkspacePathsLive), | 356 | Layer.provideMerge(WorkspacePaths.layer), |
| 357 | Layer.provideMerge(VcsProcess.layer), | 357 | Layer.provideMerge(VcsProcess.layer), |
| 358 | ); | 358 | ); |
| 359 | const orchestrationReactorLayer = OrchestrationReactorLive.pipe( | 359 | const orchestrationReactorLayer = OrchestrationReactorLive.pipe( |
| ... | @@ -378,7 +378,7 @@ export const makeOrchestrationIntegrationHarness = ( | ... | @@ -378,7 +378,7 @@ export const makeOrchestrationIntegrationHarness = ( |
| 378 | Layer.provideMerge(orchestrationReactorLayer), | 378 | Layer.provideMerge(orchestrationReactorLayer), |
| 379 | Layer.provideMerge(providerRegistryLayer), | 379 | Layer.provideMerge(providerRegistryLayer), |
| 380 | Layer.provide(persistenceLayer), | 380 | Layer.provide(persistenceLayer), |
| 381 | Layer.provideMerge(RepositoryIdentityResolverLive), | 381 | Layer.provideMerge(RepositoryIdentityResolver.layer), |
| 382 | Layer.provideMerge(ServerSettingsService.layerTest()), | 382 | Layer.provideMerge(ServerSettingsService.layerTest()), |
| 383 | Layer.provideMerge(ServerConfig.layerTest(workspaceDir, rootDir)), | 383 | Layer.provideMerge(ServerConfig.layerTest(workspaceDir, rootDir)), |
| 384 | Layer.provideMerge(NodeServices.layer), | 384 | Layer.provideMerge(NodeServices.layer), |
apps/server/src/assets/AssetAccess.test.ts+7-7| ... | @@ -7,18 +7,18 @@ import * as Layer from "effect/Layer"; | ... | @@ -7,18 +7,18 @@ import * as Layer from "effect/Layer"; |
| 7 | import * as Path from "effect/Path"; | 7 | import * as Path from "effect/Path"; |
| 8 | 8 | ||
| 9 | import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; | 9 | import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; |
| 10 | import { ServerConfig } from "../config.ts"; | 10 | import * as ServerConfig from "../config.ts"; |
| 11 | import { ProjectFaviconResolverLive } from "../project/Layers/ProjectFaviconResolver.ts"; | 11 | import * as ProjectFaviconResolver from "../project/ProjectFaviconResolver.ts"; |
| 12 | import { WorkspacePathsLive } from "../workspace/Layers/WorkspacePaths.ts"; | 12 | import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; |
| 13 | import { ASSET_ROUTE_PREFIX, issueAssetUrl, resolveAsset } from "./AssetAccess.ts"; | 13 | import { ASSET_ROUTE_PREFIX, issueAssetUrl, resolveAsset } from "./AssetAccess.ts"; |
| 14 | 14 | ||
| 15 | const configLayer = ServerConfig.layerTest(process.cwd(), { | 15 | const configLayer = ServerConfig.ServerConfig.layerTest(process.cwd(), { |
| 16 | prefix: "t3-asset-access-test-", | 16 | prefix: "t3-asset-access-test-", |
| 17 | }); | 17 | }); |
| 18 | const testLayer = Layer.mergeAll( | 18 | const testLayer = Layer.mergeAll( |
| 19 | configLayer, | 19 | configLayer, |
| 20 | WorkspacePathsLive, | 20 | WorkspacePaths.layer, |
| 21 | ProjectFaviconResolverLive.pipe(Layer.provide(WorkspacePathsLive)), | 21 | ProjectFaviconResolver.layer.pipe(Layer.provide(WorkspacePaths.layer)), |
| 22 | ServerSecretStore.layer.pipe(Layer.provide(configLayer)), | 22 | ServerSecretStore.layer.pipe(Layer.provide(configLayer)), |
| 23 | ).pipe(Layer.provideMerge(NodeServices.layer)); | 23 | ).pipe(Layer.provideMerge(NodeServices.layer)); |
| 24 | 24 | ||
| ... | @@ -127,7 +127,7 @@ describe("AssetAccess", () => { | ... | @@ -127,7 +127,7 @@ describe("AssetAccess", () => { |
| 127 | 127 | ||
| 128 | it.effect("issues exact attachment capabilities by attachment id", () => | 128 | it.effect("issues exact attachment capabilities by attachment id", () => |
| 129 | Effect.gen(function* () { | 129 | Effect.gen(function* () { |
| 130 | const config = yield* ServerConfig; | 130 | const config = yield* ServerConfig.ServerConfig; |
| 131 | const fileSystem = yield* FileSystem.FileSystem; | 131 | const fileSystem = yield* FileSystem.FileSystem; |
| 132 | const path = yield* Path.Path; | 132 | const path = yield* Path.Path; |
| 133 | const attachmentId = "thread-1-00000000-0000-4000-8000-000000000001"; | 133 | const attachmentId = "thread-1-00000000-0000-4000-8000-000000000001"; |
apps/server/src/assets/AssetAccess.ts+5-5| ... | @@ -22,8 +22,8 @@ import { | ... | @@ -22,8 +22,8 @@ import { |
| 22 | import { ServerSecretStore } from "../auth/ServerSecretStore.ts"; | 22 | import { ServerSecretStore } from "../auth/ServerSecretStore.ts"; |
| 23 | import { resolveAttachmentPathById } from "../attachmentStore.ts"; | 23 | import { resolveAttachmentPathById } from "../attachmentStore.ts"; |
| 24 | import { ServerConfig } from "../config.ts"; | 24 | import { ServerConfig } from "../config.ts"; |
| 25 | import { ProjectFaviconResolver } from "../project/Services/ProjectFaviconResolver.ts"; | 25 | import * as ProjectFaviconResolver from "../project/ProjectFaviconResolver.ts"; |
| 26 | import { WorkspacePaths } from "../workspace/Services/WorkspacePaths.ts"; | 26 | import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; |
| 27 | 27 | ||
| 28 | export const ASSET_ROUTE_PREFIX = "/api/assets"; | 28 | export const ASSET_ROUTE_PREFIX = "/api/assets"; |
| 29 | export const FALLBACK_PROJECT_FAVICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="#6b728080" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" data-fallback="project-favicon"><path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-8l-2-2H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2Z"/></svg>`; | 29 | export const FALLBACK_PROJECT_FAVICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="#6b728080" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" data-fallback="project-favicon"><path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-8l-2-2H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2Z"/></svg>`; |
| ... | @@ -103,7 +103,7 @@ const failAccess = (message: string, cause?: unknown) => | ... | @@ -103,7 +103,7 @@ const failAccess = (message: string, cause?: unknown) => |
| 103 | const resolveCanonicalWorkspaceFile = Effect.fn("AssetAccess.resolveCanonicalWorkspaceFile")( | 103 | const resolveCanonicalWorkspaceFile = Effect.fn("AssetAccess.resolveCanonicalWorkspaceFile")( |
| 104 | function* (input: { readonly workspaceRoot: string; readonly relativePath: string }) { | 104 | function* (input: { readonly workspaceRoot: string; readonly relativePath: string }) { |
| 105 | const fileSystem = yield* FileSystem.FileSystem; | 105 | const fileSystem = yield* FileSystem.FileSystem; |
| 106 | const workspacePaths = yield* WorkspacePaths; | 106 | const workspacePaths = yield* WorkspacePaths.WorkspacePaths; |
| 107 | const resolved = yield* workspacePaths | 107 | const resolved = yield* workspacePaths |
| 108 | .resolveRelativePathWithinRoot(input) | 108 | .resolveRelativePathWithinRoot(input) |
| 109 | .pipe(Effect.orElseSucceed(() => null)); | 109 | .pipe(Effect.orElseSucceed(() => null)); |
| ... | @@ -130,7 +130,7 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i | ... | @@ -130,7 +130,7 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i |
| 130 | }) { | 130 | }) { |
| 131 | const fileSystem = yield* FileSystem.FileSystem; | 131 | const fileSystem = yield* FileSystem.FileSystem; |
| 132 | const path = yield* Path.Path; | 132 | const path = yield* Path.Path; |
| 133 | const workspacePaths = yield* WorkspacePaths; | 133 | const workspacePaths = yield* WorkspacePaths.WorkspacePaths; |
| 134 | const expiresAt = (yield* Clock.currentTimeMillis) + ASSET_TOKEN_TTL_MS; | 134 | const expiresAt = (yield* Clock.currentTimeMillis) + ASSET_TOKEN_TTL_MS; |
| 135 | let claims: AssetClaims; | 135 | let claims: AssetClaims; |
| 136 | let fileName: string; | 136 | let fileName: string; |
| ... | @@ -202,7 +202,7 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i | ... | @@ -202,7 +202,7 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i |
| 202 | const workspaceRoot = yield* workspacePaths | 202 | const workspaceRoot = yield* workspacePaths |
| 203 | .normalizeWorkspaceRoot(input.resource.cwd) | 203 | .normalizeWorkspaceRoot(input.resource.cwd) |
| 204 | .pipe(Effect.mapError((cause) => failAccess(cause.message, cause))); | 204 | .pipe(Effect.mapError((cause) => failAccess(cause.message, cause))); |
| 205 | const faviconResolver = yield* ProjectFaviconResolver; | 205 | const faviconResolver = yield* ProjectFaviconResolver.ProjectFaviconResolver; |
| 206 | const faviconPath = yield* faviconResolver.resolvePath(workspaceRoot); | 206 | const faviconPath = yield* faviconResolver.resolvePath(workspaceRoot); |
| 207 | const relativePath = faviconPath ? path.relative(workspaceRoot, faviconPath) : null; | 207 | const relativePath = faviconPath ? path.relative(workspaceRoot, faviconPath) : null; |
| 208 | if ( | 208 | if ( |
apps/server/src/bin.test.ts+6-6| ... | @@ -1,5 +1,5 @@ | ... | @@ -1,5 +1,5 @@ |
| 1 | // @effect-diagnostics nodeBuiltinImport:off - CLI integration exercises Node HTTP and filesystem boundaries. | 1 | // @effect-diagnostics nodeBuiltinImport:off - CLI integration exercises Node HTTP and filesystem boundaries. |
| 2 | import * as NodeHttp from "node:http"; | 2 | import { createServer } from "node:http"; |
| 3 | import { existsSync, mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; | 3 | import { existsSync, mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; |
| 4 | import { tmpdir } from "node:os"; | 4 | import { tmpdir } from "node:os"; |
| 5 | import { join } from "node:path"; | 5 | import { join } from "node:path"; |
| ... | @@ -25,12 +25,12 @@ import * as ProjectionSnapshotQuery from "./orchestration/Services/ProjectionSna | ... | @@ -25,12 +25,12 @@ import * as ProjectionSnapshotQuery from "./orchestration/Services/ProjectionSna |
| 25 | import { OrchestrationLayerLive } from "./orchestration/runtimeLayer.ts"; | 25 | import { OrchestrationLayerLive } from "./orchestration/runtimeLayer.ts"; |
| 26 | import { orchestrationHttpApiLayer } from "./orchestration/http.ts"; | 26 | import { orchestrationHttpApiLayer } from "./orchestration/http.ts"; |
| 27 | import { layerConfig as SqlitePersistenceLayerLive } from "./persistence/Layers/Sqlite.ts"; | 27 | import { layerConfig as SqlitePersistenceLayerLive } from "./persistence/Layers/Sqlite.ts"; |
| 28 | import { RepositoryIdentityResolverLive } from "./project/Layers/RepositoryIdentityResolver.ts"; | 28 | import * as RepositoryIdentityResolver from "./project/RepositoryIdentityResolver.ts"; |
| 29 | import { | 29 | import { |
| 30 | makePersistedServerRuntimeState, | 30 | makePersistedServerRuntimeState, |
| 31 | persistServerRuntimeState, | 31 | persistServerRuntimeState, |
| 32 | } from "./serverRuntimeState.ts"; | 32 | } from "./serverRuntimeState.ts"; |
| 33 | import { WorkspacePathsLive } from "./workspace/Layers/WorkspacePaths.ts"; | 33 | import * as WorkspacePaths from "./workspace/WorkspacePaths.ts"; |
| 34 | import * as ServerSecretStore from "./auth/ServerSecretStore.ts"; | 34 | import * as ServerSecretStore from "./auth/ServerSecretStore.ts"; |
| 35 | import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; | 35 | import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; |
| 36 | import { environmentAuthenticatedAuthLayer } from "./auth/http.ts"; | 36 | import { environmentAuthenticatedAuthLayer } from "./auth/http.ts"; |
| ... | @@ -90,10 +90,10 @@ const makeCliTestServerConfig = (baseDir: string) => | ... | @@ -90,10 +90,10 @@ const makeCliTestServerConfig = (baseDir: string) => |
| 90 | const makeProjectPersistenceLayer = (config: ServerConfig.ServerConfig["Service"]) => | 90 | const makeProjectPersistenceLayer = (config: ServerConfig.ServerConfig["Service"]) => |
| 91 | Layer.mergeAll( | 91 | Layer.mergeAll( |
| 92 | OrchestrationLayerLive.pipe( | 92 | OrchestrationLayerLive.pipe( |
| 93 | Layer.provideMerge(RepositoryIdentityResolverLive), | 93 | Layer.provideMerge(RepositoryIdentityResolver.layer), |
| 94 | Layer.provideMerge(SqlitePersistenceLayerLive), | 94 | Layer.provideMerge(SqlitePersistenceLayerLive), |
| 95 | ), | 95 | ), |
| 96 | WorkspacePathsLive, | 96 | WorkspacePaths.layer, |
| 97 | ).pipe(Layer.provideMerge(NodeServices.layer), Layer.provide(ServerConfig.layer(config))); | 97 | ).pipe(Layer.provideMerge(NodeServices.layer), Layer.provide(ServerConfig.layer(config))); |
| 98 | 98 | ||
| 99 | const readPersistedSnapshot = (baseDir: string) => | 99 | const readPersistedSnapshot = (baseDir: string) => |
| ... | @@ -124,7 +124,7 @@ const withLiveProjectCliServer = <A, E, R>(baseDir: string, run: () => Effect.Ef | ... | @@ -124,7 +124,7 @@ const withLiveProjectCliServer = <A, E, R>(baseDir: string, run: () => Effect.Ef |
| 124 | ), | 124 | ), |
| 125 | Layer.provideMerge(makeProjectPersistenceLayer(config)), | 125 | Layer.provideMerge(makeProjectPersistenceLayer(config)), |
| 126 | Layer.provideMerge( | 126 | Layer.provideMerge( |
| 127 | NodeHttpServer.layer(NodeHttp.createServer, { | 127 | NodeHttpServer.layer(createServer, { |
| 128 | host: "127.0.0.1", | 128 | host: "127.0.0.1", |
| 129 | port: 0, | 129 | port: 0, |
| 130 | }), | 130 | }), |
apps/server/src/cli/connect.ts+2-3| ... | @@ -32,8 +32,7 @@ import { CLOUD_LINKED_USER_ID, RELAY_URL_SECRET } from "../cloud/config.ts"; | ... | @@ -32,8 +32,7 @@ import { CLOUD_LINKED_USER_ID, RELAY_URL_SECRET } from "../cloud/config.ts"; |
| 32 | import { relayUrlConfig } from "../cloud/publicConfig.ts"; | 32 | import { relayUrlConfig } from "../cloud/publicConfig.ts"; |
| 33 | import { headlessRelayClientTracingLayer } from "../cloud/relayTracing.ts"; | 33 | import { headlessRelayClientTracingLayer } from "../cloud/relayTracing.ts"; |
| 34 | import * as ServerConfig from "../config.ts"; | 34 | import * as ServerConfig from "../config.ts"; |
| 35 | import { ServerEnvironmentLive } from "../environment/Layers/ServerEnvironment.ts"; | 35 | import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; |
| 36 | import * as ServerEnvironment from "../environment/Services/ServerEnvironment.ts"; | ||
| 37 | import { readPersistedServerRuntimeState } from "../serverRuntimeState.ts"; | 36 | import { readPersistedServerRuntimeState } from "../serverRuntimeState.ts"; |
| 38 | import { projectLocationFlags, resolveCliAuthConfig } from "./config.ts"; | 37 | import { projectLocationFlags, resolveCliAuthConfig } from "./config.ts"; |
| 39 | 38 | ||
| ... | @@ -301,7 +300,7 @@ const runCloudCommand = <A, E>( | ... | @@ -301,7 +300,7 @@ const runCloudCommand = <A, E>( |
| 301 | CliTokenManager.layer.pipe(Layer.provide(ServerSecretStore.layer)), | 300 | CliTokenManager.layer.pipe(Layer.provide(ServerSecretStore.layer)), |
| 302 | RelayClient.layerCloudflared({ baseDir: config.baseDir }), | 301 | RelayClient.layerCloudflared({ baseDir: config.baseDir }), |
| 303 | EnvironmentAuth.runtimeLayer, | 302 | EnvironmentAuth.runtimeLayer, |
| 304 | ServerEnvironmentLive, | 303 | ServerEnvironment.layer, |
| 305 | headlessRelayClientTracingLayer, | 304 | headlessRelayClientTracingLayer, |
| 306 | ).pipe( | 305 | ).pipe( |
| 307 | Layer.provideMerge(FetchHttpClient.layer), | 306 | Layer.provideMerge(FetchHttpClient.layer), |
apps/server/src/cli/project.ts+5-6| ... | @@ -31,14 +31,13 @@ import * as OrchestrationEngine from "../orchestration/Services/OrchestrationEng | ... | @@ -31,14 +31,13 @@ import * as OrchestrationEngine from "../orchestration/Services/OrchestrationEng |
| 31 | import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts"; | 31 | import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts"; |
| 32 | import { OrchestrationLayerLive } from "../orchestration/runtimeLayer.ts"; | 32 | import { OrchestrationLayerLive } from "../orchestration/runtimeLayer.ts"; |
| 33 | import { layerConfig as SqlitePersistenceLayerLive } from "../persistence/Layers/Sqlite.ts"; | 33 | import { layerConfig as SqlitePersistenceLayerLive } from "../persistence/Layers/Sqlite.ts"; |
| 34 | import { RepositoryIdentityResolverLive } from "../project/Layers/RepositoryIdentityResolver.ts"; | 34 | import * as RepositoryIdentityResolver from "../project/RepositoryIdentityResolver.ts"; |
| 35 | import * as ServerRuntimeStartup from "../serverRuntimeStartup.ts"; | 35 | import * as ServerRuntimeStartup from "../serverRuntimeStartup.ts"; |
| 36 | import { | 36 | import { |
| 37 | clearPersistedServerRuntimeState, | 37 | clearPersistedServerRuntimeState, |
| 38 | readPersistedServerRuntimeState, | 38 | readPersistedServerRuntimeState, |
| 39 | } from "../serverRuntimeState.ts"; | 39 | } from "../serverRuntimeState.ts"; |
| 40 | import { WorkspacePathsLive } from "../workspace/Layers/WorkspacePaths.ts"; | 40 | import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; |
| 41 | import * as WorkspacePaths from "../workspace/Services/WorkspacePaths.ts"; | ||
| 42 | import { type CliAuthLocationFlags, projectLocationFlags, resolveCliAuthConfig } from "./config.ts"; | 41 | import { type CliAuthLocationFlags, projectLocationFlags, resolveCliAuthConfig } from "./config.ts"; |
| 43 | 42 | ||
| 44 | type ProjectMutationTarget = { | 43 | type ProjectMutationTarget = { |
| ... | @@ -68,9 +67,9 @@ const projectCommandUuid = Crypto.Crypto.pipe( | ... | @@ -68,9 +67,9 @@ const projectCommandUuid = Crypto.Crypto.pipe( |
| 68 | ); | 67 | ); |
| 69 | 68 | ||
| 70 | const ProjectCliRuntimeLive = Layer.mergeAll( | 69 | const ProjectCliRuntimeLive = Layer.mergeAll( |
| 71 | WorkspacePathsLive, | 70 | WorkspacePaths.layer, |
| 72 | OrchestrationLayerLive.pipe( | 71 | OrchestrationLayerLive.pipe( |
| 73 | Layer.provideMerge(RepositoryIdentityResolverLive), | 72 | Layer.provideMerge(RepositoryIdentityResolver.layer), |
| 74 | Layer.provideMerge(SqlitePersistenceLayerLive), | 73 | Layer.provideMerge(SqlitePersistenceLayerLive), |
| 75 | ), | 74 | ), |
| 76 | ); | 75 | ); |
| ... | @@ -301,7 +300,7 @@ const runProjectMutation = Effect.fn("runProjectMutation")(function* ( | ... | @@ -301,7 +300,7 @@ const runProjectMutation = Effect.fn("runProjectMutation")(function* ( |
| 301 | }).pipe(Effect.provide(offlineRuntimeLayer)); | 300 | }).pipe(Effect.provide(offlineRuntimeLayer)); |
| 302 | }).pipe( | 301 | }).pipe( |
| 303 | Effect.provide( | 302 | Effect.provide( |
| 304 | Layer.mergeAll(EnvironmentAuth.runtimeLayer, WorkspacePathsLive).pipe( | 303 | Layer.mergeAll(EnvironmentAuth.runtimeLayer, WorkspacePaths.layer).pipe( |
| 305 | Layer.provideMerge(FetchHttpClient.layer), | 304 | Layer.provideMerge(FetchHttpClient.layer), |
| 306 | Layer.provide(ServerConfig.layer(config)), | 305 | Layer.provide(ServerConfig.layer(config)), |
| 307 | Layer.provide(Layer.succeed(References.MinimumLogLevel, minimumLogLevel)), | 306 | Layer.provide(Layer.succeed(References.MinimumLogLevel, minimumLogLevel)), |
apps/server/src/cloud/http.test.ts+1-1| ... | @@ -9,7 +9,7 @@ import { HttpClient, HttpServerRequest } from "effect/unstable/http"; | ... | @@ -9,7 +9,7 @@ import { HttpClient, HttpServerRequest } from "effect/unstable/http"; |
| 9 | import { RelayClientTracer } from "@t3tools/shared/relayTracing"; | 9 | import { RelayClientTracer } from "@t3tools/shared/relayTracing"; |
| 10 | import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts"; | 10 | import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts"; |
| 11 | import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; | 11 | import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; |
| 12 | import * as ServerEnvironment from "../environment/Services/ServerEnvironment.ts"; | 12 | import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; |
| 13 | import * as CliTokenManager from "./CliTokenManager.ts"; | 13 | import * as CliTokenManager from "./CliTokenManager.ts"; |
| 14 | import { consumeCloudReplayGuards, reconcileDesiredCloudLink } from "./http.ts"; | 14 | import { consumeCloudReplayGuards, reconcileDesiredCloudLink } from "./http.ts"; |
| 15 | import * as ManagedEndpointRuntime from "./ManagedEndpointRuntime.ts"; | 15 | import * as ManagedEndpointRuntime from "./ManagedEndpointRuntime.ts"; |
apps/server/src/cloud/http.ts+3-3| ... | @@ -1,4 +1,4 @@ | ... | @@ -1,4 +1,4 @@ |
| 1 | import * as NodeCrypto from "node:crypto"; | 1 | import { createPublicKey } from "node:crypto"; |
| 2 | import { | 2 | import { |
| 3 | AuthRelayReadScope, | 3 | AuthRelayReadScope, |
| 4 | AuthRelayWriteScope, | 4 | AuthRelayWriteScope, |
| ... | @@ -55,7 +55,7 @@ import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder"; | ... | @@ -55,7 +55,7 @@ import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder"; |
| 55 | import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts"; | 55 | import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts"; |
| 56 | import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; | 56 | import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; |
| 57 | import { requireEnvironmentScope } from "../auth/http.ts"; | 57 | import { requireEnvironmentScope } from "../auth/http.ts"; |
| 58 | import * as ServerEnvironment from "../environment/Services/ServerEnvironment.ts"; | 58 | import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; |
| 59 | import * as ManagedEndpointRuntime from "./ManagedEndpointRuntime.ts"; | 59 | import * as ManagedEndpointRuntime from "./ManagedEndpointRuntime.ts"; |
| 60 | import { | 60 | import { |
| 61 | CLOUD_ENDPOINT_RUNTIME_CONFIG, | 61 | CLOUD_ENDPOINT_RUNTIME_CONFIG, |
| ... | @@ -152,7 +152,7 @@ function validateCloudMintPublicKey( | ... | @@ -152,7 +152,7 @@ function validateCloudMintPublicKey( |
| 152 | publicKey: string, | 152 | publicKey: string, |
| 153 | ): Effect.Effect<void, EnvironmentHttpBadRequestError> { | 153 | ): Effect.Effect<void, EnvironmentHttpBadRequestError> { |
| 154 | return Effect.try({ | 154 | return Effect.try({ |
| 155 | try: () => NodeCrypto.createPublicKey(publicKey.replace(/\\n/g, "\n")), | 155 | try: () => createPublicKey(publicKey.replace(/\\n/g, "\n")), |
| 156 | catch: () => | 156 | catch: () => |
| 157 | new EnvironmentHttpBadRequestError({ | 157 | new EnvironmentHttpBadRequestError({ |
| 158 | message: "Cloud mint public key must be a valid Ed25519 public key.", | 158 | message: "Cloud mint public key must be a valid Ed25519 public key.", |
apps/server/src/environment/Layers/ServerEnvironment.test.ts deleted-130| ... | @@ -1,130 +0,0 @@ | ||
| 1 | // @effect-diagnostics nodeBuiltinImport:off | ||
| 2 | import * as nodePath from "node:path"; | ||
| 3 | import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| 4 | import { expect, it } from "@effect/vitest"; | ||
| 5 | import * as Effect from "effect/Effect"; | ||
| 6 | import * as Exit from "effect/Exit"; | ||
| 7 | import * as FileSystem from "effect/FileSystem"; | ||
| 8 | import * as Layer from "effect/Layer"; | ||
| 9 | import * as PlatformError from "effect/PlatformError"; | ||
| 10 | |||
| 11 | import * as ServerConfig from "../../config.ts"; | ||
| 12 | import * as ServerEnvironment from "../Services/ServerEnvironment.ts"; | ||
| 13 | import { ServerEnvironmentLive } from "./ServerEnvironment.ts"; | ||
| 14 | |||
| 15 | const makeServerEnvironmentLayer = (baseDir: string) => | ||
| 16 | ServerEnvironmentLive.pipe(Layer.provide(ServerConfig.layerTest(process.cwd(), baseDir))); | ||
| 17 | |||
| 18 | const makeServerConfig = Effect.fn(function* (baseDir: string) { | ||
| 19 | const derivedPaths = yield* ServerConfig.deriveServerPaths(baseDir, undefined); | ||
| 20 | |||
| 21 | return { | ||
| 22 | ...derivedPaths, | ||
| 23 | logLevel: "Error", | ||
| 24 | traceMinLevel: "Info", | ||
| 25 | traceTimingEnabled: true, | ||
| 26 | traceBatchWindowMs: 200, | ||
| 27 | traceMaxBytes: 10 * 1024 * 1024, | ||
| 28 | traceMaxFiles: 10, | ||
| 29 | otlpTracesUrl: undefined, | ||
| 30 | otlpMetricsUrl: undefined, | ||
| 31 | otlpExportIntervalMs: 10_000, | ||
| 32 | otlpServiceName: "t3-server", | ||
| 33 | cwd: process.cwd(), | ||
| 34 | baseDir, | ||
| 35 | mode: "web", | ||
| 36 | autoBootstrapProjectFromCwd: false, | ||
| 37 | logWebSocketEvents: false, | ||
| 38 | tailscaleServeEnabled: false, | ||
| 39 | tailscaleServePort: 443, | ||
| 40 | port: 0, | ||
| 41 | host: undefined, | ||
| 42 | desktopBootstrapToken: undefined, | ||
| 43 | staticDir: undefined, | ||
| 44 | devUrl: undefined, | ||
| 45 | noBrowser: false, | ||
| 46 | startupPresentation: "browser", | ||
| 47 | } satisfies ServerConfig.ServerConfig["Service"]; | ||
| 48 | }); | ||
| 49 | |||
| 50 | it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => { | ||
| 51 | it.effect("persists the environment id across service restarts", () => | ||
| 52 | Effect.gen(function* () { | ||
| 53 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 54 | const baseDir = yield* fileSystem.makeTempDirectoryScoped({ | ||
| 55 | prefix: "t3-server-environment-test-", | ||
| 56 | }); | ||
| 57 | |||
| 58 | const first = yield* Effect.gen(function* () { | ||
| 59 | const serverEnvironment = yield* ServerEnvironment.ServerEnvironment; | ||
| 60 | return yield* serverEnvironment.getDescriptor; | ||
| 61 | }).pipe(Effect.provide(makeServerEnvironmentLayer(baseDir))); | ||
| 62 | const second = yield* Effect.gen(function* () { | ||
| 63 | const serverEnvironment = yield* ServerEnvironment.ServerEnvironment; | ||
| 64 | return yield* serverEnvironment.getDescriptor; | ||
| 65 | }).pipe(Effect.provide(makeServerEnvironmentLayer(baseDir))); | ||
| 66 | |||
| 67 | expect(first.environmentId).toBe(second.environmentId); | ||
| 68 | expect(second.capabilities.repositoryIdentity).toBe(true); | ||
| 69 | }), | ||
| 70 | ); | ||
| 71 | |||
| 72 | it.effect("fails instead of overwriting a persisted id when reading the file errors", () => | ||
| 73 | Effect.gen(function* () { | ||
| 74 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 75 | const baseDir = yield* fileSystem.makeTempDirectoryScoped({ | ||
| 76 | prefix: "t3-server-environment-read-error-test-", | ||
| 77 | }); | ||
| 78 | const serverConfig = yield* makeServerConfig(baseDir); | ||
| 79 | const environmentIdPath = serverConfig.environmentIdPath; | ||
| 80 | yield* fileSystem.makeDirectory(nodePath.dirname(environmentIdPath), { recursive: true }); | ||
| 81 | yield* fileSystem.writeFileString(environmentIdPath, "persisted-environment-id\n"); | ||
| 82 | const writeAttempts: string[] = []; | ||
| 83 | const failingFileSystemLayer = FileSystem.layerNoop({ | ||
| 84 | exists: (path) => Effect.succeed(path === environmentIdPath), | ||
| 85 | readFileString: (path) => | ||
| 86 | path === environmentIdPath | ||
| 87 | ? Effect.fail( | ||
| 88 | PlatformError.systemError({ | ||
| 89 | _tag: "PermissionDenied", | ||
| 90 | module: "FileSystem", | ||
| 91 | method: "readFileString", | ||
| 92 | description: "permission denied", | ||
| 93 | pathOrDescriptor: path, | ||
| 94 | }), | ||
| 95 | ) | ||
| 96 | : Effect.fail( | ||
| 97 | PlatformError.systemError({ | ||
| 98 | _tag: "NotFound", | ||
| 99 | module: "FileSystem", | ||
| 100 | method: "readFileString", | ||
| 101 | description: "not found", | ||
| 102 | pathOrDescriptor: path, | ||
| 103 | }), | ||
| 104 | ), | ||
| 105 | writeFileString: (path) => { | ||
| 106 | writeAttempts.push(path); | ||
| 107 | return Effect.void; | ||
| 108 | }, | ||
| 109 | }); | ||
| 110 | |||
| 111 | const exit = yield* Effect.gen(function* () { | ||
| 112 | const serverEnvironment = yield* ServerEnvironment.ServerEnvironment; | ||
| 113 | return yield* serverEnvironment.getDescriptor; | ||
| 114 | }).pipe( | ||
| 115 | Effect.provide( | ||
| 116 | ServerEnvironmentLive.pipe( | ||
| 117 | Layer.provide(Layer.merge(ServerConfig.layer(serverConfig), failingFileSystemLayer)), | ||
| 118 | ), | ||
| 119 | ), | ||
| 120 | Effect.exit, | ||
| 121 | ); | ||
| 122 | |||
| 123 | expect(Exit.isFailure(exit)).toBe(true); | ||
| 124 | expect(writeAttempts).toEqual([]); | ||
| 125 | expect(yield* fileSystem.readFileString(environmentIdPath)).toBe( | ||
| 126 | "persisted-environment-id\n", | ||
| 127 | ); | ||
| 128 | }), | ||
| 129 | ); | ||
| 130 | }); | ||
apps/server/src/environment/Layers/ServerEnvironment.ts deleted-105| ... | @@ -1,105 +0,0 @@ | ||
| 1 | import { EnvironmentId, type ExecutionEnvironmentDescriptor } from "@t3tools/contracts"; | ||
| 2 | import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess"; | ||
| 3 | import * as Crypto from "effect/Crypto"; | ||
| 4 | import * as Effect from "effect/Effect"; | ||
| 5 | import * as FileSystem from "effect/FileSystem"; | ||
| 6 | import * as Layer from "effect/Layer"; | ||
| 7 | import * as Path from "effect/Path"; | ||
| 8 | |||
| 9 | import { ServerConfig } from "../../config.ts"; | ||
| 10 | import { layer as ProcessRunnerLive } from "../../processRunner.ts"; | ||
| 11 | import { ServerEnvironment, type ServerEnvironmentShape } from "../Services/ServerEnvironment.ts"; | ||
| 12 | import packageJson from "../../../package.json" with { type: "json" }; | ||
| 13 | import { resolveServerEnvironmentLabel } from "./ServerEnvironmentLabel.ts"; | ||
| 14 | |||
| 15 | function platformOs(platform: NodeJS.Platform): ExecutionEnvironmentDescriptor["platform"]["os"] { | ||
| 16 | switch (platform) { | ||
| 17 | case "darwin": | ||
| 18 | return "darwin"; | ||
| 19 | case "linux": | ||
| 20 | return "linux"; | ||
| 21 | case "win32": | ||
| 22 | return "windows"; | ||
| 23 | default: | ||
| 24 | return "unknown"; | ||
| 25 | } | ||
| 26 | } | ||
| 27 | |||
| 28 | function platformArch( | ||
| 29 | architecture: NodeJS.Architecture, | ||
| 30 | ): ExecutionEnvironmentDescriptor["platform"]["arch"] { | ||
| 31 | switch (architecture) { | ||
| 32 | case "arm64": | ||
| 33 | return "arm64"; | ||
| 34 | case "x64": | ||
| 35 | return "x64"; | ||
| 36 | default: | ||
| 37 | return "other"; | ||
| 38 | } | ||
| 39 | } | ||
| 40 | |||
| 41 | export const makeServerEnvironment = Effect.fn("makeServerEnvironment")(function* () { | ||
| 42 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 43 | const path = yield* Path.Path; | ||
| 44 | const serverConfig = yield* ServerConfig; | ||
| 45 | const crypto = yield* Crypto.Crypto; | ||
| 46 | const hostPlatform = yield* HostProcessPlatform; | ||
| 47 | const hostArchitecture = yield* HostProcessArchitecture; | ||
| 48 | |||
| 49 | const readPersistedEnvironmentId = Effect.gen(function* () { | ||
| 50 | const exists = yield* fileSystem | ||
| 51 | .exists(serverConfig.environmentIdPath) | ||
| 52 | .pipe(Effect.orElseSucceed(() => false)); | ||
| 53 | if (!exists) { | ||
| 54 | return null; | ||
| 55 | } | ||
| 56 | |||
| 57 | const raw = yield* fileSystem | ||
| 58 | .readFileString(serverConfig.environmentIdPath) | ||
| 59 | .pipe(Effect.map((value) => value.trim())); | ||
| 60 | |||
| 61 | return raw.length > 0 ? raw : null; | ||
| 62 | }); | ||
| 63 | |||
| 64 | const persistEnvironmentId = (value: string) => | ||
| 65 | fileSystem.writeFileString(serverConfig.environmentIdPath, `${value}\n`); | ||
| 66 | |||
| 67 | const environmentIdRaw = yield* Effect.gen(function* () { | ||
| 68 | const persisted = yield* readPersistedEnvironmentId; | ||
| 69 | if (persisted) { | ||
| 70 | return persisted; | ||
| 71 | } | ||
| 72 | |||
| 73 | const generated = yield* crypto.randomUUIDv4; | ||
| 74 | yield* persistEnvironmentId(generated); | ||
| 75 | return generated; | ||
| 76 | }); | ||
| 77 | |||
| 78 | const environmentId = EnvironmentId.make(environmentIdRaw); | ||
| 79 | const cwdBaseName = path.basename(serverConfig.cwd).trim(); | ||
| 80 | const label = yield* resolveServerEnvironmentLabel({ | ||
| 81 | cwdBaseName, | ||
| 82 | }); | ||
| 83 | |||
| 84 | const descriptor: ExecutionEnvironmentDescriptor = { | ||
| 85 | environmentId, | ||
| 86 | label, | ||
| 87 | platform: { | ||
| 88 | os: platformOs(hostPlatform), | ||
| 89 | arch: platformArch(hostArchitecture), | ||
| 90 | }, | ||
| 91 | serverVersion: packageJson.version, | ||
| 92 | capabilities: { | ||
| 93 | repositoryIdentity: true, | ||
| 94 | }, | ||
| 95 | }; | ||
| 96 | |||
| 97 | return { | ||
| 98 | getEnvironmentId: Effect.succeed(environmentId), | ||
| 99 | getDescriptor: Effect.succeed(descriptor), | ||
| 100 | } satisfies ServerEnvironmentShape; | ||
| 101 | }); | ||
| 102 | |||
| 103 | export const ServerEnvironmentLive = Layer.effect(ServerEnvironment, makeServerEnvironment()).pipe( | ||
| 104 | Layer.provide(ProcessRunnerLive), | ||
| 105 | ); | ||
apps/server/src/environment/Layers/ServerEnvironmentLabel.test.ts deleted-175| ... | @@ -1,175 +0,0 @@ | ||
| 1 | import { afterEach, describe, expect, it } from "@effect/vitest"; | ||
| 2 | import * as Effect from "effect/Effect"; | ||
| 3 | import * as FileSystem from "effect/FileSystem"; | ||
| 4 | import * as Layer from "effect/Layer"; | ||
| 5 | import { HostProcessHostname, HostProcessPlatform } from "@t3tools/shared/hostProcess"; | ||
| 6 | import { vi } from "vite-plus/test"; | ||
| 7 | |||
| 8 | import * as ProcessRunner from "../../processRunner.ts"; | ||
| 9 | import { resolveServerEnvironmentLabel } from "./ServerEnvironmentLabel.ts"; | ||
| 10 | import { ChildProcessSpawner } from "effect/unstable/process"; | ||
| 11 | |||
| 12 | const runMock = vi.fn<ProcessRunner.ProcessRunner["Service"]["run"]>(); | ||
| 13 | |||
| 14 | const ProcessRunnerTest = Layer.succeed( | ||
| 15 | ProcessRunner.ProcessRunner, | ||
| 16 | ProcessRunner.ProcessRunner.of({ | ||
| 17 | run: (input) => runMock(input), | ||
| 18 | }), | ||
| 19 | ); | ||
| 20 | const NoopFileSystemLayer = FileSystem.layerNoop({}); | ||
| 21 | const TestLayer = Layer.merge(NoopFileSystemLayer, ProcessRunnerTest); | ||
| 22 | const LinuxMachineInfoLayer = Layer.merge( | ||
| 23 | ProcessRunnerTest, | ||
| 24 | FileSystem.layerNoop({ | ||
| 25 | exists: (path) => Effect.succeed(path === "/etc/machine-info"), | ||
| 26 | readFileString: (path) => | ||
| 27 | path === "/etc/machine-info" | ||
| 28 | ? Effect.succeed('PRETTY_HOSTNAME="Build Agent 01"\nICON_NAME="computer-vm"\n') | ||
| 29 | : Effect.succeed(""), | ||
| 30 | }), | ||
| 31 | ); | ||
| 32 | const withHostPlatform = <ROut, E, RIn>( | ||
| 33 | layer: Layer.Layer<ROut, E, RIn>, | ||
| 34 | platform: NodeJS.Platform, | ||
| 35 | hostname: string, | ||
| 36 | ) => | ||
| 37 | Layer.mergeAll( | ||
| 38 | layer, | ||
| 39 | Layer.succeed(HostProcessPlatform, platform), | ||
| 40 | Layer.succeed(HostProcessHostname, hostname), | ||
| 41 | ); | ||
| 42 | |||
| 43 | afterEach(() => { | ||
| 44 | runMock.mockReset(); | ||
| 45 | }); | ||
| 46 | |||
| 47 | describe("resolveServerEnvironmentLabel", () => { | ||
| 48 | it.effect("uses hostname fallback regardless of launch mode", () => | ||
| 49 | Effect.gen(function* () { | ||
| 50 | const result = yield* resolveServerEnvironmentLabel({ | ||
| 51 | cwdBaseName: "t3code", | ||
| 52 | }).pipe(Effect.provide(withHostPlatform(TestLayer, "win32", "macbook-pro"))); | ||
| 53 | |||
| 54 | expect(result).toBe("macbook-pro"); | ||
| 55 | }), | ||
| 56 | ); | ||
| 57 | |||
| 58 | it.effect("prefers the macOS ComputerName", () => | ||
| 59 | Effect.gen(function* () { | ||
| 60 | runMock.mockReturnValueOnce( | ||
| 61 | Effect.succeed({ | ||
| 62 | stdout: " Julius's MacBook Pro \n", | ||
| 63 | stderr: "", | ||
| 64 | code: ChildProcessSpawner.ExitCode(0), | ||
| 65 | timedOut: false, | ||
| 66 | stdoutTruncated: false, | ||
| 67 | stderrTruncated: false, | ||
| 68 | }), | ||
| 69 | ); | ||
| 70 | |||
| 71 | const result = yield* resolveServerEnvironmentLabel({ | ||
| 72 | cwdBaseName: "t3code", | ||
| 73 | }).pipe(Effect.provide(withHostPlatform(TestLayer, "darwin", "macbook-pro"))); | ||
| 74 | |||
| 75 | expect(result).toBe("Julius's MacBook Pro"); | ||
| 76 | expect(runMock).toHaveBeenCalledWith( | ||
| 77 | expect.objectContaining({ | ||
| 78 | command: "scutil", | ||
| 79 | args: ["--get", "ComputerName"], | ||
| 80 | timeoutBehavior: "timedOutResult", | ||
| 81 | }), | ||
| 82 | ); | ||
| 83 | }), | ||
| 84 | ); | ||
| 85 | |||
| 86 | it.effect("prefers Linux PRETTY_HOSTNAME from machine-info", () => | ||
| 87 | Effect.gen(function* () { | ||
| 88 | const result = yield* resolveServerEnvironmentLabel({ | ||
| 89 | cwdBaseName: "t3code", | ||
| 90 | }).pipe(Effect.provide(withHostPlatform(LinuxMachineInfoLayer, "linux", "buildbox"))); | ||
| 91 | |||
| 92 | expect(result).toBe("Build Agent 01"); | ||
| 93 | expect(runMock).not.toHaveBeenCalled(); | ||
| 94 | }), | ||
| 95 | ); | ||
| 96 | |||
| 97 | it.effect("falls back to hostnamectl pretty hostname on Linux", () => | ||
| 98 | Effect.gen(function* () { | ||
| 99 | runMock.mockReturnValueOnce( | ||
| 100 | Effect.succeed({ | ||
| 101 | stdout: "CI Runner\n", | ||
| 102 | stderr: "", | ||
| 103 | code: ChildProcessSpawner.ExitCode(0), | ||
| 104 | timedOut: false, | ||
| 105 | stdoutTruncated: false, | ||
| 106 | stderrTruncated: false, | ||
| 107 | }), | ||
| 108 | ); | ||
| 109 | |||
| 110 | const result = yield* resolveServerEnvironmentLabel({ | ||
| 111 | cwdBaseName: "t3code", | ||
| 112 | }).pipe(Effect.provide(withHostPlatform(TestLayer, "linux", "runner-01"))); | ||
| 113 | |||
| 114 | expect(result).toBe("CI Runner"); | ||
| 115 | expect(runMock).toHaveBeenCalledWith( | ||
| 116 | expect.objectContaining({ | ||
| 117 | command: "hostnamectl", | ||
| 118 | args: ["--pretty"], | ||
| 119 | timeoutBehavior: "timedOutResult", | ||
| 120 | }), | ||
| 121 | ); | ||
| 122 | }), | ||
| 123 | ); | ||
| 124 | |||
| 125 | it.effect("falls back to the hostname when friendly labels are unavailable", () => | ||
| 126 | Effect.gen(function* () { | ||
| 127 | const result = yield* resolveServerEnvironmentLabel({ | ||
| 128 | cwdBaseName: "t3code", | ||
| 129 | }).pipe(Effect.provide(withHostPlatform(TestLayer, "win32", "JULIUS-LAPTOP"))); | ||
| 130 | |||
| 131 | expect(result).toBe("JULIUS-LAPTOP"); | ||
| 132 | }), | ||
| 133 | ); | ||
| 134 | |||
| 135 | it.effect("falls back to the hostname when the friendly-label command is missing", () => | ||
| 136 | Effect.gen(function* () { | ||
| 137 | runMock.mockReturnValueOnce( | ||
| 138 | Effect.fail( | ||
| 139 | new ProcessRunner.ProcessSpawnError({ | ||
| 140 | command: "scutil", | ||
| 141 | args: ["--get", "ComputerName"], | ||
| 142 | cause: new Error("spawn scutil ENOENT"), | ||
| 143 | }), | ||
| 144 | ), | ||
| 145 | ); | ||
| 146 | |||
| 147 | const result = yield* resolveServerEnvironmentLabel({ | ||
| 148 | cwdBaseName: "t3code", | ||
| 149 | }).pipe(Effect.provide(withHostPlatform(TestLayer, "darwin", "macbook-pro"))); | ||
| 150 | |||
| 151 | expect(result).toBe("macbook-pro"); | ||
| 152 | }), | ||
| 153 | ); | ||
| 154 | |||
| 155 | it.effect("falls back to the cwd basename when the hostname is blank", () => | ||
| 156 | Effect.gen(function* () { | ||
| 157 | runMock.mockReturnValueOnce( | ||
| 158 | Effect.succeed({ | ||
| 159 | stdout: " ", | ||
| 160 | stderr: "", | ||
| 161 | code: ChildProcessSpawner.ExitCode(0), | ||
| 162 | timedOut: false, | ||
| 163 | stdoutTruncated: false, | ||
| 164 | stderrTruncated: false, | ||
| 165 | }), | ||
| 166 | ); | ||
| 167 | |||
| 168 | const result = yield* resolveServerEnvironmentLabel({ | ||
| 169 | cwdBaseName: "t3code", | ||
| 170 | }).pipe(Effect.provide(withHostPlatform(TestLayer, "linux", " "))); | ||
| 171 | |||
| 172 | expect(result).toBe("t3code"); | ||
| 173 | }), | ||
| 174 | ); | ||
| 175 | }); | ||
apps/server/src/environment/Layers/ServerEnvironmentLabel.ts deleted-104| ... | @@ -1,104 +0,0 @@ | ||
| 1 | import { HostProcessHostname, HostProcessPlatform } from "@t3tools/shared/hostProcess"; | ||
| 2 | import * as Effect from "effect/Effect"; | ||
| 3 | import * as FileSystem from "effect/FileSystem"; | ||
| 4 | import * as Option from "effect/Option"; | ||
| 5 | |||
| 6 | import { ProcessRunner } from "../../processRunner.ts"; | ||
| 7 | |||
| 8 | interface ResolveServerEnvironmentLabelInput { | ||
| 9 | readonly cwdBaseName: string; | ||
| 10 | } | ||
| 11 | |||
| 12 | function normalizeLabel(value: string | null | undefined): string | null { | ||
| 13 | const trimmed = value?.trim(); | ||
| 14 | return trimmed && trimmed.length > 0 ? trimmed : null; | ||
| 15 | } | ||
| 16 | |||
| 17 | function parseMachineInfoValue(raw: string, key: string): string | null { | ||
| 18 | for (const line of raw.split(/\r?\n/g)) { | ||
| 19 | const trimmed = line.trim(); | ||
| 20 | if (trimmed.length === 0 || trimmed.startsWith("#") || !trimmed.startsWith(`${key}=`)) { | ||
| 21 | continue; | ||
| 22 | } | ||
| 23 | const value = trimmed.slice(key.length + 1).trim(); | ||
| 24 | if ( | ||
| 25 | (value.startsWith('"') && value.endsWith('"')) || | ||
| 26 | (value.startsWith("'") && value.endsWith("'")) | ||
| 27 | ) { | ||
| 28 | return normalizeLabel(value.slice(1, -1)); | ||
| 29 | } | ||
| 30 | return normalizeLabel(value); | ||
| 31 | } | ||
| 32 | return null; | ||
| 33 | } | ||
| 34 | |||
| 35 | const readLinuxMachineInfo = Effect.fn("readLinuxMachineInfo")(function* () { | ||
| 36 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 37 | const exists = yield* fileSystem | ||
| 38 | .exists("/etc/machine-info") | ||
| 39 | .pipe(Effect.orElseSucceed(() => false)); | ||
| 40 | if (!exists) { | ||
| 41 | return null; | ||
| 42 | } | ||
| 43 | |||
| 44 | return yield* fileSystem | ||
| 45 | .readFileString("/etc/machine-info") | ||
| 46 | .pipe(Effect.orElseSucceed(() => null)); | ||
| 47 | }); | ||
| 48 | |||
| 49 | const runFriendlyLabelCommand = Effect.fn("runFriendlyLabelCommand")(function* ( | ||
| 50 | command: string, | ||
| 51 | args: readonly string[], | ||
| 52 | ) { | ||
| 53 | const processRunner = yield* ProcessRunner; | ||
| 54 | const result = yield* processRunner | ||
| 55 | .run({ | ||
| 56 | command, | ||
| 57 | args, | ||
| 58 | timeoutBehavior: "timedOutResult", | ||
| 59 | }) | ||
| 60 | .pipe(Effect.option); | ||
| 61 | |||
| 62 | if (Option.isNone(result) || result.value.code !== 0) { | ||
| 63 | return null; | ||
| 64 | } | ||
| 65 | |||
| 66 | return normalizeLabel(result.value.stdout); | ||
| 67 | }); | ||
| 68 | |||
| 69 | const resolveFriendlyHostLabel = Effect.fn("resolveFriendlyHostLabel")(function* () { | ||
| 70 | const platform = yield* HostProcessPlatform; | ||
| 71 | if (platform === "darwin") { | ||
| 72 | return yield* runFriendlyLabelCommand("scutil", ["--get", "ComputerName"]); | ||
| 73 | } | ||
| 74 | |||
| 75 | if (platform === "linux") { | ||
| 76 | const machineInfo = normalizeLabel(yield* readLinuxMachineInfo()); | ||
| 77 | if (machineInfo) { | ||
| 78 | const prettyHostname = parseMachineInfoValue(machineInfo, "PRETTY_HOSTNAME"); | ||
| 79 | if (prettyHostname) { | ||
| 80 | return prettyHostname; | ||
| 81 | } | ||
| 82 | } | ||
| 83 | |||
| 84 | return yield* runFriendlyLabelCommand("hostnamectl", ["--pretty"]); | ||
| 85 | } | ||
| 86 | |||
| 87 | return null; | ||
| 88 | }); | ||
| 89 | |||
| 90 | export const resolveServerEnvironmentLabel = Effect.fn("resolveServerEnvironmentLabel")(function* ( | ||
| 91 | input: ResolveServerEnvironmentLabelInput, | ||
| 92 | ) { | ||
| 93 | const friendlyHostLabel = yield* resolveFriendlyHostLabel(); | ||
| 94 | if (friendlyHostLabel) { | ||
| 95 | return friendlyHostLabel; | ||
| 96 | } | ||
| 97 | |||
| 98 | const hostname = normalizeLabel(yield* HostProcessHostname); | ||
| 99 | if (hostname) { | ||
| 100 | return hostname; | ||
| 101 | } | ||
| 102 | |||
| 103 | return normalizeLabel(input.cwdBaseName) ?? "T3 environment"; | ||
| 104 | }); | ||
apps/server/src/environment/ServerEnvironment.test.ts created+129| ... | @@ -0,0 +1,129 @@ | ||
| 1 | // @effect-diagnostics nodeBuiltinImport:off | ||
| 2 | import { dirname } from "node:path"; | ||
| 3 | import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| 4 | import { expect, it } from "@effect/vitest"; | ||
| 5 | import * as Effect from "effect/Effect"; | ||
| 6 | import * as Exit from "effect/Exit"; | ||
| 7 | import * as FileSystem from "effect/FileSystem"; | ||
| 8 | import * as Layer from "effect/Layer"; | ||
| 9 | import * as PlatformError from "effect/PlatformError"; | ||
| 10 | |||
| 11 | import * as ServerConfig from "../config.ts"; | ||
| 12 | import * as ServerEnvironment from "./ServerEnvironment.ts"; | ||
| 13 | |||
| 14 | const makeServerEnvironmentLayer = (baseDir: string) => | ||
| 15 | ServerEnvironment.layer.pipe(Layer.provide(ServerConfig.layerTest(process.cwd(), baseDir))); | ||
| 16 | |||
| 17 | const makeServerConfig = Effect.fn(function* (baseDir: string) { | ||
| 18 | const derivedPaths = yield* ServerConfig.deriveServerPaths(baseDir, undefined); | ||
| 19 | |||
| 20 | return { | ||
| 21 | ...derivedPaths, | ||
| 22 | logLevel: "Error", | ||
| 23 | traceMinLevel: "Info", | ||
| 24 | traceTimingEnabled: true, | ||
| 25 | traceBatchWindowMs: 200, | ||
| 26 | traceMaxBytes: 10 * 1024 * 1024, | ||
| 27 | traceMaxFiles: 10, | ||
| 28 | otlpTracesUrl: undefined, | ||
| 29 | otlpMetricsUrl: undefined, | ||
| 30 | otlpExportIntervalMs: 10_000, | ||
| 31 | otlpServiceName: "t3-server", | ||
| 32 | cwd: process.cwd(), | ||
| 33 | baseDir, | ||
| 34 | mode: "web", | ||
| 35 | autoBootstrapProjectFromCwd: false, | ||
| 36 | logWebSocketEvents: false, | ||
| 37 | tailscaleServeEnabled: false, | ||
| 38 | tailscaleServePort: 443, | ||
| 39 | port: 0, | ||
| 40 | host: undefined, | ||
| 41 | desktopBootstrapToken: undefined, | ||
| 42 | staticDir: undefined, | ||
| 43 | devUrl: undefined, | ||
| 44 | noBrowser: false, | ||
| 45 | startupPresentation: "browser", | ||
| 46 | } satisfies ServerConfig.ServerConfig["Service"]; | ||
| 47 | }); | ||
| 48 | |||
| 49 | it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => { | ||
| 50 | it.effect("persists the environment id across service restarts", () => | ||
| 51 | Effect.gen(function* () { | ||
| 52 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 53 | const baseDir = yield* fileSystem.makeTempDirectoryScoped({ | ||
| 54 | prefix: "t3-server-environment-test-", | ||
| 55 | }); | ||
| 56 | |||
| 57 | const first = yield* Effect.gen(function* () { | ||
| 58 | const serverEnvironment = yield* ServerEnvironment.ServerEnvironment; | ||
| 59 | return yield* serverEnvironment.getDescriptor; | ||
| 60 | }).pipe(Effect.provide(makeServerEnvironmentLayer(baseDir))); | ||
| 61 | const second = yield* Effect.gen(function* () { | ||
| 62 | const serverEnvironment = yield* ServerEnvironment.ServerEnvironment; | ||
| 63 | return yield* serverEnvironment.getDescriptor; | ||
| 64 | }).pipe(Effect.provide(makeServerEnvironmentLayer(baseDir))); | ||
| 65 | |||
| 66 | expect(first.environmentId).toBe(second.environmentId); | ||
| 67 | expect(second.capabilities.repositoryIdentity).toBe(true); | ||
| 68 | }), | ||
| 69 | ); | ||
| 70 | |||
| 71 | it.effect("fails instead of overwriting a persisted id when reading the file errors", () => | ||
| 72 | Effect.gen(function* () { | ||
| 73 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 74 | const baseDir = yield* fileSystem.makeTempDirectoryScoped({ | ||
| 75 | prefix: "t3-server-environment-read-error-test-", | ||
| 76 | }); | ||
| 77 | const serverConfig = yield* makeServerConfig(baseDir); | ||
| 78 | const environmentIdPath = serverConfig.environmentIdPath; | ||
| 79 | yield* fileSystem.makeDirectory(dirname(environmentIdPath), { recursive: true }); | ||
| 80 | yield* fileSystem.writeFileString(environmentIdPath, "persisted-environment-id\n"); | ||
| 81 | const writeAttempts: string[] = []; | ||
| 82 | const failingFileSystemLayer = FileSystem.layerNoop({ | ||
| 83 | exists: (path) => Effect.succeed(path === environmentIdPath), | ||
| 84 | readFileString: (path) => | ||
| 85 | path === environmentIdPath | ||
| 86 | ? Effect.fail( | ||
| 87 | PlatformError.systemError({ | ||
| 88 | _tag: "PermissionDenied", | ||
| 89 | module: "FileSystem", | ||
| 90 | method: "readFileString", | ||
| 91 | description: "permission denied", | ||
| 92 | pathOrDescriptor: path, | ||
| 93 | }), | ||
| 94 | ) | ||
| 95 | : Effect.fail( | ||
| 96 | PlatformError.systemError({ | ||
| 97 | _tag: "NotFound", | ||
| 98 | module: "FileSystem", | ||
| 99 | method: "readFileString", | ||
| 100 | description: "not found", | ||
| 101 | pathOrDescriptor: path, | ||
| 102 | }), | ||
| 103 | ), | ||
| 104 | writeFileString: (path) => { | ||
| 105 | writeAttempts.push(path); | ||
| 106 | return Effect.void; | ||
| 107 | }, | ||
| 108 | }); | ||
| 109 | |||
| 110 | const exit = yield* Effect.gen(function* () { | ||
| 111 | const serverEnvironment = yield* ServerEnvironment.ServerEnvironment; | ||
| 112 | return yield* serverEnvironment.getDescriptor; | ||
| 113 | }).pipe( | ||
| 114 | Effect.provide( | ||
| 115 | ServerEnvironment.layer.pipe( | ||
| 116 | Layer.provide(Layer.merge(ServerConfig.layer(serverConfig), failingFileSystemLayer)), | ||
| 117 | ), | ||
| 118 | ), | ||
| 119 | Effect.exit, | ||
| 120 | ); | ||
| 121 | |||
| 122 | expect(Exit.isFailure(exit)).toBe(true); | ||
| 123 | expect(writeAttempts).toEqual([]); | ||
| 124 | expect(yield* fileSystem.readFileString(environmentIdPath)).toBe( | ||
| 125 | "persisted-environment-id\n", | ||
| 126 | ); | ||
| 127 | }), | ||
| 128 | ); | ||
| 129 | }); | ||
apps/server/src/environment/ServerEnvironment.ts created+114| ... | @@ -0,0 +1,114 @@ | ||
| 1 | import { EnvironmentId, type ExecutionEnvironmentDescriptor } from "@t3tools/contracts"; | ||
| 2 | import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess"; | ||
| 3 | import * as Context from "effect/Context"; | ||
| 4 | import * as Crypto from "effect/Crypto"; | ||
| 5 | import * as Effect from "effect/Effect"; | ||
| 6 | import * as FileSystem from "effect/FileSystem"; | ||
| 7 | import * as Layer from "effect/Layer"; | ||
| 8 | import * as Path from "effect/Path"; | ||
| 9 | |||
| 10 | import packageJson from "../../package.json" with { type: "json" }; | ||
| 11 | import * as ServerConfig from "../config.ts"; | ||
| 12 | import * as ProcessRunner from "../processRunner.ts"; | ||
| 13 | import { resolveServerEnvironmentLabel } from "./ServerEnvironmentLabel.ts"; | ||
| 14 | |||
| 15 | export class ServerEnvironment extends Context.Service< | ||
| 16 | ServerEnvironment, | ||
| 17 | { | ||
| 18 | readonly getEnvironmentId: Effect.Effect<EnvironmentId>; | ||
| 19 | readonly getDescriptor: Effect.Effect<ExecutionEnvironmentDescriptor>; | ||
| 20 | } | ||
| 21 | >()("t3/environment/ServerEnvironment") {} | ||
| 22 | |||
| 23 | function platformOs(platform: NodeJS.Platform): ExecutionEnvironmentDescriptor["platform"]["os"] { | ||
| 24 | switch (platform) { | ||
| 25 | case "darwin": | ||
| 26 | return "darwin"; | ||
| 27 | case "linux": | ||
| 28 | return "linux"; | ||
| 29 | case "win32": | ||
| 30 | return "windows"; | ||
| 31 | default: | ||
| 32 | return "unknown"; | ||
| 33 | } | ||
| 34 | } | ||
| 35 | |||
| 36 | function platformArch( | ||
| 37 | architecture: NodeJS.Architecture, | ||
| 38 | ): ExecutionEnvironmentDescriptor["platform"]["arch"] { | ||
| 39 | switch (architecture) { | ||
| 40 | case "arm64": | ||
| 41 | return "arm64"; | ||
| 42 | case "x64": | ||
| 43 | return "x64"; | ||
| 44 | default: | ||
| 45 | return "other"; | ||
| 46 | } | ||
| 47 | } | ||
| 48 | |||
| 49 | export const make = Effect.gen(function* () { | ||
| 50 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 51 | const path = yield* Path.Path; | ||
| 52 | const serverConfig = yield* ServerConfig.ServerConfig; | ||
| 53 | const crypto = yield* Crypto.Crypto; | ||
| 54 | const hostPlatform = yield* HostProcessPlatform; | ||
| 55 | const hostArchitecture = yield* HostProcessArchitecture; | ||
| 56 | |||
| 57 | const readPersistedEnvironmentId = Effect.gen(function* () { | ||
| 58 | const exists = yield* fileSystem | ||
| 59 | .exists(serverConfig.environmentIdPath) | ||
| 60 | .pipe(Effect.orElseSucceed(() => false)); | ||
| 61 | if (!exists) { | ||
| 62 | return null; | ||
| 63 | } | ||
| 64 | |||
| 65 | const raw = yield* fileSystem | ||
| 66 | .readFileString(serverConfig.environmentIdPath) | ||
| 67 | .pipe(Effect.map((value) => value.trim())); | ||
| 68 | |||
| 69 | return raw.length > 0 ? raw : null; | ||
| 70 | }); | ||
| 71 | |||
| 72 | const persistEnvironmentId = (value: string) => | ||
| 73 | fileSystem.writeFileString(serverConfig.environmentIdPath, `${value}\n`); | ||
| 74 | |||
| 75 | const environmentIdRaw = yield* Effect.gen(function* () { | ||
| 76 | const persisted = yield* readPersistedEnvironmentId; | ||
| 77 | if (persisted) { | ||
| 78 | return persisted; | ||
| 79 | } | ||
| 80 | |||
| 81 | const generated = yield* crypto.randomUUIDv4; | ||
| 82 | yield* persistEnvironmentId(generated); | ||
| 83 | return generated; | ||
| 84 | }); | ||
| 85 | |||
| 86 | const environmentId = EnvironmentId.make(environmentIdRaw); | ||
| 87 | const cwdBaseName = path.basename(serverConfig.cwd).trim(); | ||
| 88 | const label = yield* resolveServerEnvironmentLabel({ cwdBaseName }); | ||
| 89 | |||
| 90 | const descriptor: ExecutionEnvironmentDescriptor = { | ||
| 91 | environmentId, | ||
| 92 | label, | ||
| 93 | platform: { | ||
| 94 | os: platformOs(hostPlatform), | ||
| 95 | arch: platformArch(hostArchitecture), | ||
| 96 | }, | ||
| 97 | serverVersion: packageJson.version, | ||
| 98 | capabilities: { | ||
| 99 | repositoryIdentity: true, | ||
| 100 | }, | ||
| 101 | }; | ||
| 102 | |||
| 103 | return ServerEnvironment.of({ | ||
| 104 | getEnvironmentId: Effect.succeed(environmentId), | ||
| 105 | getDescriptor: Effect.succeed(descriptor), | ||
| 106 | }); | ||
| 107 | }); | ||
| 108 | |||
| 109 | /** | ||
| 110 | * ServerEnvironment is acquired from persisted filesystem and host-process | ||
| 111 | * state. It intentionally has no fallback Layer.succeed value: callers must | ||
| 112 | * provide the external platform services and a ServerConfig. | ||
| 113 | */ | ||
| 114 | export const layer = Layer.effect(ServerEnvironment, make).pipe(Layer.provide(ProcessRunner.layer)); | ||
apps/server/src/environment/ServerEnvironmentLabel.test.ts created+175| ... | @@ -0,0 +1,175 @@ | ||
| 1 | import { afterEach, describe, expect, it } from "@effect/vitest"; | ||
| 2 | import * as Effect from "effect/Effect"; | ||
| 3 | import * as FileSystem from "effect/FileSystem"; | ||
| 4 | import * as Layer from "effect/Layer"; | ||
| 5 | import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; | ||
| 6 | import { HostProcessHostname, HostProcessPlatform } from "@t3tools/shared/hostProcess"; | ||
| 7 | import { vi } from "vite-plus/test"; | ||
| 8 | |||
| 9 | import * as ProcessRunner from "../processRunner.ts"; | ||
| 10 | import { resolveServerEnvironmentLabel } from "./ServerEnvironmentLabel.ts"; | ||
| 11 | |||
| 12 | const runMock = vi.fn<ProcessRunner.ProcessRunner["Service"]["run"]>(); | ||
| 13 | |||
| 14 | const ProcessRunnerTest = Layer.succeed( | ||
| 15 | ProcessRunner.ProcessRunner, | ||
| 16 | ProcessRunner.ProcessRunner.of({ | ||
| 17 | run: (input) => runMock(input), | ||
| 18 | }), | ||
| 19 | ); | ||
| 20 | const NoopFileSystemLayer = FileSystem.layerNoop({}); | ||
| 21 | const TestLayer = Layer.merge(NoopFileSystemLayer, ProcessRunnerTest); | ||
| 22 | const LinuxMachineInfoLayer = Layer.merge( | ||
| 23 | ProcessRunnerTest, | ||
| 24 | FileSystem.layerNoop({ | ||
| 25 | exists: (path) => Effect.succeed(path === "/etc/machine-info"), | ||
| 26 | readFileString: (path) => | ||
| 27 | path === "/etc/machine-info" | ||
| 28 | ? Effect.succeed('PRETTY_HOSTNAME="Build Agent 01"\nICON_NAME="computer-vm"\n') | ||
| 29 | : Effect.succeed(""), | ||
| 30 | }), | ||
| 31 | ); | ||
| 32 | const withHostPlatform = <ROut, E, RIn>( | ||
| 33 | layer: Layer.Layer<ROut, E, RIn>, | ||
| 34 | platform: NodeJS.Platform, | ||
| 35 | hostname: string, | ||
| 36 | ) => | ||
| 37 | Layer.mergeAll( | ||
| 38 | layer, | ||
| 39 | Layer.succeed(HostProcessPlatform, platform), | ||
| 40 | Layer.succeed(HostProcessHostname, hostname), | ||
| 41 | ); | ||
| 42 | |||
| 43 | afterEach(() => { | ||
| 44 | runMock.mockReset(); | ||
| 45 | }); | ||
| 46 | |||
| 47 | describe("resolveServerEnvironmentLabel", () => { | ||
| 48 | it.effect("uses hostname fallback regardless of launch mode", () => | ||
| 49 | Effect.gen(function* () { | ||
| 50 | const result = yield* resolveServerEnvironmentLabel({ | ||
| 51 | cwdBaseName: "t3code", | ||
| 52 | }).pipe(Effect.provide(withHostPlatform(TestLayer, "win32", "macbook-pro"))); | ||
| 53 | |||
| 54 | expect(result).toBe("macbook-pro"); | ||
| 55 | }), | ||
| 56 | ); | ||
| 57 | |||
| 58 | it.effect("prefers the macOS ComputerName", () => | ||
| 59 | Effect.gen(function* () { | ||
| 60 | runMock.mockReturnValueOnce( | ||
| 61 | Effect.succeed({ | ||
| 62 | stdout: " Julius's MacBook Pro \n", | ||
| 63 | stderr: "", | ||
| 64 | code: ChildProcessSpawner.ExitCode(0), | ||
| 65 | timedOut: false, | ||
| 66 | stdoutTruncated: false, | ||
| 67 | stderrTruncated: false, | ||
| 68 | }), | ||
| 69 | ); | ||
| 70 | |||
| 71 | const result = yield* resolveServerEnvironmentLabel({ | ||
| 72 | cwdBaseName: "t3code", | ||
| 73 | }).pipe(Effect.provide(withHostPlatform(TestLayer, "darwin", "macbook-pro"))); | ||
| 74 | |||
| 75 | expect(result).toBe("Julius's MacBook Pro"); | ||
| 76 | expect(runMock).toHaveBeenCalledWith( | ||
| 77 | expect.objectContaining({ | ||
| 78 | command: "scutil", | ||
| 79 | args: ["--get", "ComputerName"], | ||
| 80 | timeoutBehavior: "timedOutResult", | ||
| 81 | }), | ||
| 82 | ); | ||
| 83 | }), | ||
| 84 | ); | ||
| 85 | |||
| 86 | it.effect("prefers Linux PRETTY_HOSTNAME from machine-info", () => | ||
| 87 | Effect.gen(function* () { | ||
| 88 | const result = yield* resolveServerEnvironmentLabel({ | ||
| 89 | cwdBaseName: "t3code", | ||
| 90 | }).pipe(Effect.provide(withHostPlatform(LinuxMachineInfoLayer, "linux", "buildbox"))); | ||
| 91 | |||
| 92 | expect(result).toBe("Build Agent 01"); | ||
| 93 | expect(runMock).not.toHaveBeenCalled(); | ||
| 94 | }), | ||
| 95 | ); | ||
| 96 | |||
| 97 | it.effect("falls back to hostnamectl pretty hostname on Linux", () => | ||
| 98 | Effect.gen(function* () { | ||
| 99 | runMock.mockReturnValueOnce( | ||
| 100 | Effect.succeed({ | ||
| 101 | stdout: "CI Runner\n", | ||
| 102 | stderr: "", | ||
| 103 | code: ChildProcessSpawner.ExitCode(0), | ||
| 104 | timedOut: false, | ||
| 105 | stdoutTruncated: false, | ||
| 106 | stderrTruncated: false, | ||
| 107 | }), | ||
| 108 | ); | ||
| 109 | |||
| 110 | const result = yield* resolveServerEnvironmentLabel({ | ||
| 111 | cwdBaseName: "t3code", | ||
| 112 | }).pipe(Effect.provide(withHostPlatform(TestLayer, "linux", "runner-01"))); | ||
| 113 | |||
| 114 | expect(result).toBe("CI Runner"); | ||
| 115 | expect(runMock).toHaveBeenCalledWith( | ||
| 116 | expect.objectContaining({ | ||
| 117 | command: "hostnamectl", | ||
| 118 | args: ["--pretty"], | ||
| 119 | timeoutBehavior: "timedOutResult", | ||
| 120 | }), | ||
| 121 | ); | ||
| 122 | }), | ||
| 123 | ); | ||
| 124 | |||
| 125 | it.effect("falls back to the hostname when friendly labels are unavailable", () => | ||
| 126 | Effect.gen(function* () { | ||
| 127 | const result = yield* resolveServerEnvironmentLabel({ | ||
| 128 | cwdBaseName: "t3code", | ||
| 129 | }).pipe(Effect.provide(withHostPlatform(TestLayer, "win32", "JULIUS-LAPTOP"))); | ||
| 130 | |||
| 131 | expect(result).toBe("JULIUS-LAPTOP"); | ||
| 132 | }), | ||
| 133 | ); | ||
| 134 | |||
| 135 | it.effect("falls back to the hostname when the friendly-label command is missing", () => | ||
| 136 | Effect.gen(function* () { | ||
| 137 | runMock.mockReturnValueOnce( | ||
| 138 | Effect.fail( | ||
| 139 | new ProcessRunner.ProcessSpawnError({ | ||
| 140 | command: "scutil", | ||
| 141 | args: ["--get", "ComputerName"], | ||
| 142 | cause: new Error("spawn scutil ENOENT"), | ||
| 143 | }), | ||
| 144 | ), | ||
| 145 | ); | ||
| 146 | |||
| 147 | const result = yield* resolveServerEnvironmentLabel({ | ||
| 148 | cwdBaseName: "t3code", | ||
| 149 | }).pipe(Effect.provide(withHostPlatform(TestLayer, "darwin", "macbook-pro"))); | ||
| 150 | |||
| 151 | expect(result).toBe("macbook-pro"); | ||
| 152 | }), | ||
| 153 | ); | ||
| 154 | |||
| 155 | it.effect("falls back to the cwd basename when the hostname is blank", () => | ||
| 156 | Effect.gen(function* () { | ||
| 157 | runMock.mockReturnValueOnce( | ||
| 158 | Effect.succeed({ | ||
| 159 | stdout: " ", | ||
| 160 | stderr: "", | ||
| 161 | code: ChildProcessSpawner.ExitCode(0), | ||
| 162 | timedOut: false, | ||
| 163 | stdoutTruncated: false, | ||
| 164 | stderrTruncated: false, | ||
| 165 | }), | ||
| 166 | ); | ||
| 167 | |||
| 168 | const result = yield* resolveServerEnvironmentLabel({ | ||
| 169 | cwdBaseName: "t3code", | ||
| 170 | }).pipe(Effect.provide(withHostPlatform(TestLayer, "linux", " "))); | ||
| 171 | |||
| 172 | expect(result).toBe("t3code"); | ||
| 173 | }), | ||
| 174 | ); | ||
| 175 | }); | ||
apps/server/src/environment/ServerEnvironmentLabel.ts created+104| ... | @@ -0,0 +1,104 @@ | ||
| 1 | import { HostProcessHostname, HostProcessPlatform } from "@t3tools/shared/hostProcess"; | ||
| 2 | import * as Effect from "effect/Effect"; | ||
| 3 | import * as FileSystem from "effect/FileSystem"; | ||
| 4 | import * as Option from "effect/Option"; | ||
| 5 | |||
| 6 | import * as ProcessRunner from "../processRunner.ts"; | ||
| 7 | |||
| 8 | interface ResolveServerEnvironmentLabelInput { | ||
| 9 | readonly cwdBaseName: string; | ||
| 10 | } | ||
| 11 | |||
| 12 | function normalizeLabel(value: string | null | undefined): string | null { | ||
| 13 | const trimmed = value?.trim(); | ||
| 14 | return trimmed && trimmed.length > 0 ? trimmed : null; | ||
| 15 | } | ||
| 16 | |||
| 17 | function parseMachineInfoValue(raw: string, key: string): string | null { | ||
| 18 | for (const line of raw.split(/\r?\n/g)) { | ||
| 19 | const trimmed = line.trim(); | ||
| 20 | if (trimmed.length === 0 || trimmed.startsWith("#") || !trimmed.startsWith(`${key}=`)) { | ||
| 21 | continue; | ||
| 22 | } | ||
| 23 | const value = trimmed.slice(key.length + 1).trim(); | ||
| 24 | if ( | ||
| 25 | (value.startsWith('"') && value.endsWith('"')) || | ||
| 26 | (value.startsWith("'") && value.endsWith("'")) | ||
| 27 | ) { | ||
| 28 | return normalizeLabel(value.slice(1, -1)); | ||
| 29 | } | ||
| 30 | return normalizeLabel(value); | ||
| 31 | } | ||
| 32 | return null; | ||
| 33 | } | ||
| 34 | |||
| 35 | const readLinuxMachineInfo = Effect.fn("readLinuxMachineInfo")(function* () { | ||
| 36 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 37 | const exists = yield* fileSystem | ||
| 38 | .exists("/etc/machine-info") | ||
| 39 | .pipe(Effect.orElseSucceed(() => false)); | ||
| 40 | if (!exists) { | ||
| 41 | return null; | ||
| 42 | } | ||
| 43 | |||
| 44 | return yield* fileSystem | ||
| 45 | .readFileString("/etc/machine-info") | ||
| 46 | .pipe(Effect.orElseSucceed(() => null)); | ||
| 47 | }); | ||
| 48 | |||
| 49 | const runFriendlyLabelCommand = Effect.fn("runFriendlyLabelCommand")(function* ( | ||
| 50 | command: string, | ||
| 51 | args: readonly string[], | ||
| 52 | ) { | ||
| 53 | const processRunner = yield* ProcessRunner.ProcessRunner; | ||
| 54 | const result = yield* processRunner | ||
| 55 | .run({ | ||
| 56 | command, | ||
| 57 | args, | ||
| 58 | timeoutBehavior: "timedOutResult", | ||
| 59 | }) | ||
| 60 | .pipe(Effect.option); | ||
| 61 | |||
| 62 | if (Option.isNone(result) || result.value.code !== 0) { | ||
| 63 | return null; | ||
| 64 | } | ||
| 65 | |||
| 66 | return normalizeLabel(result.value.stdout); | ||
| 67 | }); | ||
| 68 | |||
| 69 | const resolveFriendlyHostLabel = Effect.fn("resolveFriendlyHostLabel")(function* () { | ||
| 70 | const platform = yield* HostProcessPlatform; | ||
| 71 | if (platform === "darwin") { | ||
| 72 | return yield* runFriendlyLabelCommand("scutil", ["--get", "ComputerName"]); | ||
| 73 | } | ||
| 74 | |||
| 75 | if (platform === "linux") { | ||
| 76 | const machineInfo = normalizeLabel(yield* readLinuxMachineInfo()); | ||
| 77 | if (machineInfo) { | ||
| 78 | const prettyHostname = parseMachineInfoValue(machineInfo, "PRETTY_HOSTNAME"); | ||
| 79 | if (prettyHostname) { | ||
| 80 | return prettyHostname; | ||
| 81 | } | ||
| 82 | } | ||
| 83 | |||
| 84 | return yield* runFriendlyLabelCommand("hostnamectl", ["--pretty"]); | ||
| 85 | } | ||
| 86 | |||
| 87 | return null; | ||
| 88 | }); | ||
| 89 | |||
| 90 | export const resolveServerEnvironmentLabel = Effect.fn("resolveServerEnvironmentLabel")(function* ( | ||
| 91 | input: ResolveServerEnvironmentLabelInput, | ||
| 92 | ) { | ||
| 93 | const friendlyHostLabel = yield* resolveFriendlyHostLabel(); | ||
| 94 | if (friendlyHostLabel) { | ||
| 95 | return friendlyHostLabel; | ||
| 96 | } | ||
| 97 | |||
| 98 | const hostname = normalizeLabel(yield* HostProcessHostname); | ||
| 99 | if (hostname) { | ||
| 100 | return hostname; | ||
| 101 | } | ||
| 102 | |||
| 103 | return normalizeLabel(input.cwdBaseName) ?? "T3 environment"; | ||
| 104 | }); | ||
apps/server/src/environment/Services/ServerEnvironment.ts deleted-12| ... | @@ -1,12 +0,0 @@ | ||
| 1 | import type { EnvironmentId, ExecutionEnvironmentDescriptor } from "@t3tools/contracts"; | ||
| 2 | import * as Context from "effect/Context"; | ||
| 3 | import type * as Effect from "effect/Effect"; | ||
| 4 | |||
| 5 | export interface ServerEnvironmentShape { | ||
| 6 | readonly getEnvironmentId: Effect.Effect<EnvironmentId>; | ||
| 7 | readonly getDescriptor: Effect.Effect<ExecutionEnvironmentDescriptor>; | ||
| 8 | } | ||
| 9 | |||
| 10 | export class ServerEnvironment extends Context.Service<ServerEnvironment, ServerEnvironmentShape>()( | ||
| 11 | "t3/environment/Services/ServerEnvironment", | ||
| 12 | ) {} | ||
apps/server/src/git/GitManager.test.ts+8-5| ... | @@ -20,7 +20,6 @@ import type { | ... | @@ -20,7 +20,6 @@ import type { |
| 20 | } from "@t3tools/contracts"; | 20 | } from "@t3tools/contracts"; |
| 21 | 21 | ||
| 22 | import { GitCommandError, TextGenerationError } from "@t3tools/contracts"; | 22 | import { GitCommandError, TextGenerationError } from "@t3tools/contracts"; |
| 23 | import * as GitManager from "./GitManager.ts"; | ||
| 24 | import * as GitHubCli from "../sourceControl/GitHubCli.ts"; | 23 | import * as GitHubCli from "../sourceControl/GitHubCli.ts"; |
| 25 | import * as TextGeneration from "../textGeneration/TextGeneration.ts"; | 24 | import * as TextGeneration from "../textGeneration/TextGeneration.ts"; |
| 26 | import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; | 25 | import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; |
| ... | @@ -28,8 +27,9 @@ import * as VcsProcess from "../vcs/VcsProcess.ts"; | ... | @@ -28,8 +27,9 @@ import * as VcsProcess from "../vcs/VcsProcess.ts"; |
| 28 | import * as GitHubSourceControlProvider from "../sourceControl/GitHubSourceControlProvider.ts"; | 27 | import * as GitHubSourceControlProvider from "../sourceControl/GitHubSourceControlProvider.ts"; |
| 29 | import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts"; | 28 | import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts"; |
| 30 | import * as ServerConfig from "../config.ts"; | 29 | import * as ServerConfig from "../config.ts"; |
| 30 | import * as ProjectSetupScriptRunner from "../project/ProjectSetupScriptRunner.ts"; | ||
| 31 | import * as ServerSettings from "../serverSettings.ts"; | 31 | import * as ServerSettings from "../serverSettings.ts"; |
| 32 | import * as ProjectSetupScriptRunner from "../project/Services/ProjectSetupScriptRunner.ts"; | 32 | import * as GitManager from "./GitManager.ts"; |
| 33 | 33 | ||
| 34 | interface FakeGhScenario { | 34 | interface FakeGhScenario { |
| 35 | prListSequence?: string[]; | 35 | prListSequence?: string[]; |
| ... | @@ -3215,10 +3215,13 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { | ... | @@ -3215,10 +3215,13 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { |
| 3215 | }, | 3215 | }, |
| 3216 | }, | 3216 | }, |
| 3217 | setupScriptRunner: { | 3217 | setupScriptRunner: { |
| 3218 | runForThread: () => | 3218 | runForThread: (input) => |
| 3219 | Effect.fail( | 3219 | Effect.fail( |
| 3220 | new ProjectSetupScriptRunner.ProjectSetupScriptRunnerError({ | 3220 | new ProjectSetupScriptRunner.ProjectSetupScriptOperationError({ |
| 3221 | message: "terminal start failed", | 3221 | threadId: input.threadId, |
| 3222 | worktreePath: input.worktreePath, | ||
| 3223 | operation: "openTerminal", | ||
| 3224 | cause: new Error("terminal start failed"), | ||
| 3222 | }), | 3225 | }), |
| 3223 | ), | 3226 | ), |
| 3224 | }, | 3227 | }, |
apps/server/src/git/GitManager.ts+1-1| ... | @@ -43,7 +43,7 @@ import { | ... | @@ -43,7 +43,7 @@ import { |
| 43 | 43 | ||
| 44 | import { GitManagerError } from "@t3tools/contracts"; | 44 | import { GitManagerError } from "@t3tools/contracts"; |
| 45 | import * as TextGeneration from "../textGeneration/TextGeneration.ts"; | 45 | import * as TextGeneration from "../textGeneration/TextGeneration.ts"; |
| 46 | import * as ProjectSetupScriptRunner from "../project/Services/ProjectSetupScriptRunner.ts"; | 46 | import * as ProjectSetupScriptRunner from "../project/ProjectSetupScriptRunner.ts"; |
| 47 | import { extractBranchNameFromRemoteRef } from "./remoteRefs.ts"; | 47 | import { extractBranchNameFromRemoteRef } from "./remoteRefs.ts"; |
| 48 | import * as ServerSettings from "../serverSettings.ts"; | 48 | import * as ServerSettings from "../serverSettings.ts"; |
| 49 | import type { GitManagerServiceError } from "@t3tools/contracts"; | 49 | import type { GitManagerServiceError } from "@t3tools/contracts"; |
apps/server/src/http.ts+1-1| ... | @@ -39,7 +39,7 @@ import { | ... | @@ -39,7 +39,7 @@ import { |
| 39 | failEnvironmentAuthInvalid, | 39 | failEnvironmentAuthInvalid, |
| 40 | failEnvironmentInternal, | 40 | failEnvironmentInternal, |
| 41 | } from "./auth/http.ts"; | 41 | } from "./auth/http.ts"; |
| 42 | import * as ServerEnvironment from "./environment/Services/ServerEnvironment.ts"; | 42 | import * as ServerEnvironment from "./environment/ServerEnvironment.ts"; |
| 43 | import { browserApiCorsAllowedHeaders, browserApiCorsAllowedMethods } from "./httpCors.ts"; | 43 | import { browserApiCorsAllowedHeaders, browserApiCorsAllowedMethods } from "./httpCors.ts"; |
| 44 | 44 | ||
| 45 | const OTLP_TRACES_PROXY_PATH = "/api/observability/v1/traces"; | 45 | const OTLP_TRACES_PROXY_PATH = "/api/observability/v1/traces"; |
apps/server/src/mcp/McpSessionRegistry.test.ts+3-3| ... | @@ -4,7 +4,7 @@ import { EnvironmentId, ProviderInstanceId, ThreadId } from "@t3tools/contracts" | ... | @@ -4,7 +4,7 @@ import { EnvironmentId, ProviderInstanceId, ThreadId } from "@t3tools/contracts" |
| 4 | import * as Effect from "effect/Effect"; | 4 | import * as Effect from "effect/Effect"; |
| 5 | import { HttpServer } from "effect/unstable/http"; | 5 | import { HttpServer } from "effect/unstable/http"; |
| 6 | 6 | ||
| 7 | import { ServerEnvironment } from "../environment/Services/ServerEnvironment.ts"; | 7 | import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; |
| 8 | import * as McpSessionRegistry from "./McpSessionRegistry.ts"; | 8 | import * as McpSessionRegistry from "./McpSessionRegistry.ts"; |
| 9 | 9 | ||
| 10 | const environmentId = EnvironmentId.make("environment-1"); | 10 | const environmentId = EnvironmentId.make("environment-1"); |
| ... | @@ -14,7 +14,7 @@ const makeFakeHttpServer = (hostname: string, port = 43123) => | ... | @@ -14,7 +14,7 @@ const makeFakeHttpServer = (hostname: string, port = 43123) => |
| 14 | serve: (() => Effect.void) as HttpServer.HttpServer["Service"]["serve"], | 14 | serve: (() => Effect.void) as HttpServer.HttpServer["Service"]["serve"], |
| 15 | }); | 15 | }); |
| 16 | const fakeHttpServer = makeFakeHttpServer("127.0.0.1"); | 16 | const fakeHttpServer = makeFakeHttpServer("127.0.0.1"); |
| 17 | const fakeEnvironment = ServerEnvironment.of({ | 17 | const fakeEnvironment = ServerEnvironment.ServerEnvironment.of({ |
| 18 | getEnvironmentId: Effect.succeed(environmentId), | 18 | getEnvironmentId: Effect.succeed(environmentId), |
| 19 | getDescriptor: Effect.die("unused"), | 19 | getDescriptor: Effect.die("unused"), |
| 20 | }); | 20 | }); |
| ... | @@ -28,7 +28,7 @@ const makeRegistry = (now: () => number, httpServer = fakeHttpServer) => | ... | @@ -28,7 +28,7 @@ const makeRegistry = (now: () => number, httpServer = fakeHttpServer) => |
| 28 | }) | 28 | }) |
| 29 | .pipe( | 29 | .pipe( |
| 30 | Effect.provideService(HttpServer.HttpServer, httpServer), | 30 | Effect.provideService(HttpServer.HttpServer, httpServer), |
| 31 | Effect.provideService(ServerEnvironment, fakeEnvironment), | 31 | Effect.provideService(ServerEnvironment.ServerEnvironment, fakeEnvironment), |
| 32 | Effect.provide(NodeServices.layer), | 32 | Effect.provide(NodeServices.layer), |
| 33 | ); | 33 | ); |
| 34 | 34 |
apps/server/src/mcp/McpSessionRegistry.ts+3-3| ... | @@ -7,7 +7,7 @@ import * as Layer from "effect/Layer"; | ... | @@ -7,7 +7,7 @@ import * as Layer from "effect/Layer"; |
| 7 | import * as SynchronizedRef from "effect/SynchronizedRef"; | 7 | import * as SynchronizedRef from "effect/SynchronizedRef"; |
| 8 | import { HttpServer } from "effect/unstable/http"; | 8 | import { HttpServer } from "effect/unstable/http"; |
| 9 | 9 | ||
| 10 | import { ServerEnvironment } from "../environment/Services/ServerEnvironment.ts"; | 10 | import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; |
| 11 | import * as McpInvocationContext from "./McpInvocationContext.ts"; | 11 | import * as McpInvocationContext from "./McpInvocationContext.ts"; |
| 12 | import * as McpProviderSession from "./McpProviderSession.ts"; | 12 | import * as McpProviderSession from "./McpProviderSession.ts"; |
| 13 | 13 | ||
| ... | @@ -75,7 +75,7 @@ const makeWithOptions = Effect.fn("McpSessionRegistry.make")(function* ( | ... | @@ -75,7 +75,7 @@ const makeWithOptions = Effect.fn("McpSessionRegistry.make")(function* ( |
| 75 | options: McpSessionRegistryOptions = {}, | 75 | options: McpSessionRegistryOptions = {}, |
| 76 | ) { | 76 | ) { |
| 77 | const crypto = yield* Crypto.Crypto; | 77 | const crypto = yield* Crypto.Crypto; |
| 78 | const environment = yield* ServerEnvironment; | 78 | const environment = yield* ServerEnvironment.ServerEnvironment; |
| 79 | const environmentId = yield* environment.getEnvironmentId; | 79 | const environmentId = yield* environment.getEnvironmentId; |
| 80 | const httpServer = yield* HttpServer.HttpServer; | 80 | const httpServer = yield* HttpServer.HttpServer; |
| 81 | const state = yield* SynchronizedRef.make<RegistryState>({ records: new Map() }); | 81 | const state = yield* SynchronizedRef.make<RegistryState>({ records: new Map() }); |
| ... | @@ -194,7 +194,7 @@ const make = Effect.acquireRelease( | ... | @@ -194,7 +194,7 @@ const make = Effect.acquireRelease( |
| 194 | export const layer: Layer.Layer< | 194 | export const layer: Layer.Layer< |
| 195 | McpSessionRegistry, | 195 | McpSessionRegistry, |
| 196 | never, | 196 | never, |
| 197 | Crypto.Crypto | ServerEnvironment | HttpServer.HttpServer | 197 | Crypto.Crypto | ServerEnvironment.ServerEnvironment | HttpServer.HttpServer |
| 198 | > = Layer.effect(McpSessionRegistry, make); | 198 | > = Layer.effect(McpSessionRegistry, make); |
| 199 | 199 | ||
| 200 | export const issueActiveMcpCredential = ( | 200 | export const issueActiveMcpCredential = ( |
apps/server/src/orchestration/Layers/CheckpointReactor.test.ts+6-6| ... | @@ -34,7 +34,7 @@ import * as CheckpointStore from "../../checkpointing/CheckpointStore.ts"; | ... | @@ -34,7 +34,7 @@ import * as CheckpointStore from "../../checkpointing/CheckpointStore.ts"; |
| 34 | import * as VcsDriverRegistry from "../../vcs/VcsDriverRegistry.ts"; | 34 | import * as VcsDriverRegistry from "../../vcs/VcsDriverRegistry.ts"; |
| 35 | import * as VcsProcess from "../../vcs/VcsProcess.ts"; | 35 | import * as VcsProcess from "../../vcs/VcsProcess.ts"; |
| 36 | import { VcsStatusBroadcaster } from "../../vcs/VcsStatusBroadcaster.ts"; | 36 | import { VcsStatusBroadcaster } from "../../vcs/VcsStatusBroadcaster.ts"; |
| 37 | import { RepositoryIdentityResolverLive } from "../../project/Layers/RepositoryIdentityResolver.ts"; | 37 | import * as RepositoryIdentityResolver from "../../project/RepositoryIdentityResolver.ts"; |
| 38 | import { CheckpointReactorLive } from "./CheckpointReactor.ts"; | 38 | import { CheckpointReactorLive } from "./CheckpointReactor.ts"; |
| 39 | import { OrchestrationEngineLive } from "./OrchestrationEngine.ts"; | 39 | import { OrchestrationEngineLive } from "./OrchestrationEngine.ts"; |
| 40 | import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts"; | 40 | import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts"; |
| ... | @@ -56,7 +56,7 @@ import { | ... | @@ -56,7 +56,7 @@ import { |
| 56 | import { checkpointRefForThreadTurn } from "../../checkpointing/Utils.ts"; | 56 | import { checkpointRefForThreadTurn } from "../../checkpointing/Utils.ts"; |
| 57 | import { ServerConfig } from "../../config.ts"; | 57 | import { ServerConfig } from "../../config.ts"; |
| 58 | import * as WorkspaceEntries from "../../workspace/WorkspaceEntries.ts"; | 58 | import * as WorkspaceEntries from "../../workspace/WorkspaceEntries.ts"; |
| 59 | import { WorkspacePathsLive } from "../../workspace/Layers/WorkspacePaths.ts"; | 59 | import * as WorkspacePaths from "../../workspace/WorkspacePaths.ts"; |
| 60 | 60 | ||
| 61 | const asProjectId = (value: string): ProjectId => ProjectId.make(value); | 61 | const asProjectId = (value: string): ProjectId => ProjectId.make(value); |
| 62 | const asTurnId = (value: string): TurnId => TurnId.make(value); | 62 | const asTurnId = (value: string): TurnId => TurnId.make(value); |
| ... | @@ -294,11 +294,11 @@ describe("CheckpointReactor", () => { | ... | @@ -294,11 +294,11 @@ describe("CheckpointReactor", () => { |
| 294 | Layer.provide(OrchestrationProjectionPipelineLive), | 294 | Layer.provide(OrchestrationProjectionPipelineLive), |
| 295 | Layer.provide(OrchestrationEventStoreLive), | 295 | Layer.provide(OrchestrationEventStoreLive), |
| 296 | Layer.provide(OrchestrationCommandReceiptRepositoryLive), | 296 | Layer.provide(OrchestrationCommandReceiptRepositoryLive), |
| 297 | Layer.provide(RepositoryIdentityResolverLive), | 297 | Layer.provide(RepositoryIdentityResolver.layer), |
| 298 | Layer.provide(SqlitePersistenceMemory), | 298 | Layer.provide(SqlitePersistenceMemory), |
| 299 | ); | 299 | ); |
| 300 | const projectionSnapshotLayer = OrchestrationProjectionSnapshotQueryLive.pipe( | 300 | const projectionSnapshotLayer = OrchestrationProjectionSnapshotQueryLive.pipe( |
| 301 | Layer.provide(RepositoryIdentityResolverLive), | 301 | Layer.provide(RepositoryIdentityResolver.layer), |
| 302 | Layer.provide(SqlitePersistenceMemory), | 302 | Layer.provide(SqlitePersistenceMemory), |
| 303 | ); | 303 | ); |
| 304 | 304 | ||
| ... | @@ -333,11 +333,11 @@ describe("CheckpointReactor", () => { | ... | @@ -333,11 +333,11 @@ describe("CheckpointReactor", () => { |
| 333 | Layer.provideMerge(CheckpointStore.layer.pipe(Layer.provide(VcsDriverRegistry.layer))), | 333 | Layer.provideMerge(CheckpointStore.layer.pipe(Layer.provide(VcsDriverRegistry.layer))), |
| 334 | Layer.provideMerge( | 334 | Layer.provideMerge( |
| 335 | WorkspaceEntries.layer.pipe( | 335 | WorkspaceEntries.layer.pipe( |
| 336 | Layer.provide(WorkspacePathsLive), | 336 | Layer.provide(WorkspacePaths.layer), |
| 337 | Layer.provideMerge(VcsDriverRegistry.layer), | 337 | Layer.provideMerge(VcsDriverRegistry.layer), |
| 338 | ), | 338 | ), |
| 339 | ), | 339 | ), |
| 340 | Layer.provideMerge(WorkspacePathsLive), | 340 | Layer.provideMerge(WorkspacePaths.layer), |
| 341 | Layer.provideMerge(VcsProcess.layer), | 341 | Layer.provideMerge(VcsProcess.layer), |
| 342 | Layer.provideMerge(ServerConfigLayer), | 342 | Layer.provideMerge(ServerConfigLayer), |
| 343 | Layer.provideMerge(NodeServices.layer), | 343 | Layer.provideMerge(NodeServices.layer), |
apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts+5-5| ... | @@ -27,7 +27,7 @@ import { | ... | @@ -27,7 +27,7 @@ import { |
| 27 | OrchestrationEventStore, | 27 | OrchestrationEventStore, |
| 28 | type OrchestrationEventStoreShape, | 28 | type OrchestrationEventStoreShape, |
| 29 | } from "../../persistence/Services/OrchestrationEventStore.ts"; | 29 | } from "../../persistence/Services/OrchestrationEventStore.ts"; |
| 30 | import { RepositoryIdentityResolverLive } from "../../project/Layers/RepositoryIdentityResolver.ts"; | 30 | import * as RepositoryIdentityResolver from "../../project/RepositoryIdentityResolver.ts"; |
| 31 | import { OrchestrationEngineLive } from "./OrchestrationEngine.ts"; | 31 | import { OrchestrationEngineLive } from "./OrchestrationEngine.ts"; |
| 32 | import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts"; | 32 | import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts"; |
| 33 | import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts"; | 33 | import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts"; |
| ... | @@ -57,7 +57,7 @@ async function createOrchestrationSystem() { | ... | @@ -57,7 +57,7 @@ async function createOrchestrationSystem() { |
| 57 | ).pipe( | 57 | ).pipe( |
| 58 | Layer.provide(OrchestrationEventStoreLive), | 58 | Layer.provide(OrchestrationEventStoreLive), |
| 59 | Layer.provide(OrchestrationCommandReceiptRepositoryLive), | 59 | Layer.provide(OrchestrationCommandReceiptRepositoryLive), |
| 60 | Layer.provide(RepositoryIdentityResolverLive), | 60 | Layer.provide(RepositoryIdentityResolver.layer), |
| 61 | Layer.provide(SqlitePersistenceMemory), | 61 | Layer.provide(SqlitePersistenceMemory), |
| 62 | Layer.provideMerge(ServerConfigLayer), | 62 | Layer.provideMerge(ServerConfigLayer), |
| 63 | Layer.provideMerge(NodeServices.layer), | 63 | Layer.provideMerge(NodeServices.layer), |
| ... | @@ -680,7 +680,7 @@ describe("OrchestrationEngine", () => { | ... | @@ -680,7 +680,7 @@ describe("OrchestrationEngine", () => { |
| 680 | Layer.provide(OrchestrationProjectionPipelineLive), | 680 | Layer.provide(OrchestrationProjectionPipelineLive), |
| 681 | Layer.provide(Layer.succeed(OrchestrationEventStore, flakyStore)), | 681 | Layer.provide(Layer.succeed(OrchestrationEventStore, flakyStore)), |
| 682 | Layer.provide(OrchestrationCommandReceiptRepositoryLive), | 682 | Layer.provide(OrchestrationCommandReceiptRepositoryLive), |
| 683 | Layer.provide(RepositoryIdentityResolverLive), | 683 | Layer.provide(RepositoryIdentityResolver.layer), |
| 684 | Layer.provide(SqlitePersistenceMemory), | 684 | Layer.provide(SqlitePersistenceMemory), |
| 685 | Layer.provideMerge(ServerConfigLayer), | 685 | Layer.provideMerge(ServerConfigLayer), |
| 686 | Layer.provideMerge(NodeServices.layer), | 686 | Layer.provideMerge(NodeServices.layer), |
| ... | @@ -785,7 +785,7 @@ describe("OrchestrationEngine", () => { | ... | @@ -785,7 +785,7 @@ describe("OrchestrationEngine", () => { |
| 785 | Layer.provide(Layer.succeed(OrchestrationProjectionPipeline, flakyProjectionPipeline)), | 785 | Layer.provide(Layer.succeed(OrchestrationProjectionPipeline, flakyProjectionPipeline)), |
| 786 | Layer.provide(OrchestrationEventStoreLive), | 786 | Layer.provide(OrchestrationEventStoreLive), |
| 787 | Layer.provide(OrchestrationCommandReceiptRepositoryLive), | 787 | Layer.provide(OrchestrationCommandReceiptRepositoryLive), |
| 788 | Layer.provide(RepositoryIdentityResolverLive), | 788 | Layer.provide(RepositoryIdentityResolver.layer), |
| 789 | Layer.provide(SqlitePersistenceMemory), | 789 | Layer.provide(SqlitePersistenceMemory), |
| 790 | Layer.provide(NodeServices.layer), | 790 | Layer.provide(NodeServices.layer), |
| 791 | ), | 791 | ), |
| ... | @@ -928,7 +928,7 @@ describe("OrchestrationEngine", () => { | ... | @@ -928,7 +928,7 @@ describe("OrchestrationEngine", () => { |
| 928 | Layer.provide(Layer.succeed(OrchestrationProjectionPipeline, flakyProjectionPipeline)), | 928 | Layer.provide(Layer.succeed(OrchestrationProjectionPipeline, flakyProjectionPipeline)), |
| 929 | Layer.provide(Layer.succeed(OrchestrationEventStore, nonTransactionalStore)), | 929 | Layer.provide(Layer.succeed(OrchestrationEventStore, nonTransactionalStore)), |
| 930 | Layer.provide(OrchestrationCommandReceiptRepositoryLive), | 930 | Layer.provide(OrchestrationCommandReceiptRepositoryLive), |
| 931 | Layer.provide(RepositoryIdentityResolverLive), | 931 | Layer.provide(RepositoryIdentityResolver.layer), |
| 932 | Layer.provide(SqlitePersistenceMemory), | 932 | Layer.provide(SqlitePersistenceMemory), |
| 933 | Layer.provide(NodeServices.layer), | 933 | Layer.provide(NodeServices.layer), |
| 934 | ), | 934 | ), |
apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts+2-2| ... | @@ -24,7 +24,7 @@ import { | ... | @@ -24,7 +24,7 @@ import { |
| 24 | SqlitePersistenceMemory, | 24 | SqlitePersistenceMemory, |
| 25 | } from "../../persistence/Layers/Sqlite.ts"; | 25 | } from "../../persistence/Layers/Sqlite.ts"; |
| 26 | import { OrchestrationEventStore } from "../../persistence/Services/OrchestrationEventStore.ts"; | 26 | import { OrchestrationEventStore } from "../../persistence/Services/OrchestrationEventStore.ts"; |
| 27 | import { RepositoryIdentityResolverLive } from "../../project/Layers/RepositoryIdentityResolver.ts"; | 27 | import * as RepositoryIdentityResolver from "../../project/RepositoryIdentityResolver.ts"; |
| 28 | import { OrchestrationEngineLive } from "./OrchestrationEngine.ts"; | 28 | import { OrchestrationEngineLive } from "./OrchestrationEngine.ts"; |
| 29 | import { | 29 | import { |
| 30 | ORCHESTRATION_PROJECTOR_NAMES, | 30 | ORCHESTRATION_PROJECTOR_NAMES, |
| ... | @@ -2535,7 +2535,7 @@ const engineLayer = it.layer( | ... | @@ -2535,7 +2535,7 @@ const engineLayer = it.layer( |
| 2535 | Layer.provide(OrchestrationProjectionPipelineLive), | 2535 | Layer.provide(OrchestrationProjectionPipelineLive), |
| 2536 | Layer.provide(OrchestrationEventStoreLive), | 2536 | Layer.provide(OrchestrationEventStoreLive), |
| 2537 | Layer.provide(OrchestrationCommandReceiptRepositoryLive), | 2537 | Layer.provide(OrchestrationCommandReceiptRepositoryLive), |
| 2538 | Layer.provide(RepositoryIdentityResolverLive), | 2538 | Layer.provide(RepositoryIdentityResolver.layer), |
| 2539 | Layer.provideMerge(SqlitePersistenceMemory), | 2539 | Layer.provideMerge(SqlitePersistenceMemory), |
| 2540 | Layer.provideMerge( | 2540 | Layer.provideMerge( |
| 2541 | ServerConfig.layerTest(process.cwd(), { | 2541 | ServerConfig.layerTest(process.cwd(), { |
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts+3-4| ... | @@ -14,8 +14,7 @@ import * as Layer from "effect/Layer"; | ... | @@ -14,8 +14,7 @@ import * as Layer from "effect/Layer"; |
| 14 | import * as SqlClient from "effect/unstable/sql/SqlClient"; | 14 | import * as SqlClient from "effect/unstable/sql/SqlClient"; |
| 15 | 15 | ||
| 16 | import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; | 16 | import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; |
| 17 | import { RepositoryIdentityResolver } from "../../project/Services/RepositoryIdentityResolver.ts"; | 17 | import * as RepositoryIdentityResolver from "../../project/RepositoryIdentityResolver.ts"; |
| 18 | import { RepositoryIdentityResolverLive } from "../../project/Layers/RepositoryIdentityResolver.ts"; | ||
| 19 | import { ORCHESTRATION_PROJECTOR_NAMES } from "./ProjectionPipeline.ts"; | 18 | import { ORCHESTRATION_PROJECTOR_NAMES } from "./ProjectionPipeline.ts"; |
| 20 | import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts"; | 19 | import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts"; |
| 21 | import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts"; | 20 | import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts"; |
| ... | @@ -28,7 +27,7 @@ const asCheckpointRef = (value: string): CheckpointRef => CheckpointRef.make(val | ... | @@ -28,7 +27,7 @@ const asCheckpointRef = (value: string): CheckpointRef => CheckpointRef.make(val |
| 28 | 27 | ||
| 29 | const projectionSnapshotLayer = it.layer( | 28 | const projectionSnapshotLayer = it.layer( |
| 30 | OrchestrationProjectionSnapshotQueryLive.pipe( | 29 | OrchestrationProjectionSnapshotQueryLive.pipe( |
| 31 | Layer.provideMerge(RepositoryIdentityResolverLive), | 30 | Layer.provideMerge(RepositoryIdentityResolver.layer), |
| 32 | Layer.provideMerge(SqlitePersistenceMemory), | 31 | Layer.provideMerge(SqlitePersistenceMemory), |
| 33 | Layer.provideMerge(NodeServices.layer), | 32 | Layer.provideMerge(NodeServices.layer), |
| 34 | ), | 33 | ), |
| ... | @@ -1441,7 +1440,7 @@ it.effect( | ... | @@ -1441,7 +1440,7 @@ it.effect( |
| 1441 | const resolveCalls: string[] = []; | 1440 | const resolveCalls: string[] = []; |
| 1442 | const layer = OrchestrationProjectionSnapshotQueryLive.pipe( | 1441 | const layer = OrchestrationProjectionSnapshotQueryLive.pipe( |
| 1443 | Layer.provideMerge( | 1442 | Layer.provideMerge( |
| 1444 | Layer.succeed(RepositoryIdentityResolver, { | 1443 | Layer.succeed(RepositoryIdentityResolver.RepositoryIdentityResolver, { |
| 1445 | resolve: (cwd: string) => | 1444 | resolve: (cwd: string) => |
| 1446 | Effect.sync(() => { | 1445 | Effect.sync(() => { |
| 1447 | resolveCalls.push(cwd); | 1446 | resolveCalls.push(cwd); |
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts+2-2| ... | @@ -48,7 +48,7 @@ import { ProjectionThreadMessage } from "../../persistence/Services/ProjectionTh | ... | @@ -48,7 +48,7 @@ import { ProjectionThreadMessage } from "../../persistence/Services/ProjectionTh |
| 48 | import { ProjectionThreadProposedPlan } from "../../persistence/Services/ProjectionThreadProposedPlans.ts"; | 48 | import { ProjectionThreadProposedPlan } from "../../persistence/Services/ProjectionThreadProposedPlans.ts"; |
| 49 | import { ProjectionThreadSession } from "../../persistence/Services/ProjectionThreadSessions.ts"; | 49 | import { ProjectionThreadSession } from "../../persistence/Services/ProjectionThreadSessions.ts"; |
| 50 | import { ProjectionThread } from "../../persistence/Services/ProjectionThreads.ts"; | 50 | import { ProjectionThread } from "../../persistence/Services/ProjectionThreads.ts"; |
| 51 | import { RepositoryIdentityResolver } from "../../project/Services/RepositoryIdentityResolver.ts"; | 51 | import * as RepositoryIdentityResolver from "../../project/RepositoryIdentityResolver.ts"; |
| 52 | import { ORCHESTRATION_PROJECTOR_NAMES } from "./ProjectionPipeline.ts"; | 52 | import { ORCHESTRATION_PROJECTOR_NAMES } from "./ProjectionPipeline.ts"; |
| 53 | import { | 53 | import { |
| 54 | ProjectionSnapshotQuery, | 54 | ProjectionSnapshotQuery, |
| ... | @@ -262,7 +262,7 @@ function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: st | ... | @@ -262,7 +262,7 @@ function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: st |
| 262 | 262 | ||
| 263 | const makeProjectionSnapshotQuery = Effect.gen(function* () { | 263 | const makeProjectionSnapshotQuery = Effect.gen(function* () { |
| 264 | const sql = yield* SqlClient.SqlClient; | 264 | const sql = yield* SqlClient.SqlClient; |
| 265 | const repositoryIdentityResolver = yield* RepositoryIdentityResolver; | 265 | const repositoryIdentityResolver = yield* RepositoryIdentityResolver.RepositoryIdentityResolver; |
| 266 | const repositoryIdentityResolutionConcurrency = 4; | 266 | const repositoryIdentityResolutionConcurrency = 4; |
| 267 | const resolveRepositoryIdentitiesForProjects = Effect.fn( | 267 | const resolveRepositoryIdentitiesForProjects = Effect.fn( |
| 268 | "ProjectionSnapshotQuery.resolveRepositoryIdentitiesForProjects", | 268 | "ProjectionSnapshotQuery.resolveRepositoryIdentitiesForProjects", |
apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts+3-3| ... | @@ -43,7 +43,7 @@ import { | ... | @@ -43,7 +43,7 @@ import { |
| 43 | } from "../../provider/Services/ProviderService.ts"; | 43 | } from "../../provider/Services/ProviderService.ts"; |
| 44 | import { makeProviderRegistryLayer } from "../../provider/testUtils/providerRegistryMock.ts"; | 44 | import { makeProviderRegistryLayer } from "../../provider/testUtils/providerRegistryMock.ts"; |
| 45 | import { TextGeneration, type TextGenerationShape } from "../../textGeneration/TextGeneration.ts"; | 45 | import { TextGeneration, type TextGenerationShape } from "../../textGeneration/TextGeneration.ts"; |
| 46 | import { RepositoryIdentityResolverLive } from "../../project/Layers/RepositoryIdentityResolver.ts"; | 46 | import * as RepositoryIdentityResolver from "../../project/RepositoryIdentityResolver.ts"; |
| 47 | import { OrchestrationEngineLive } from "./OrchestrationEngine.ts"; | 47 | import { OrchestrationEngineLive } from "./OrchestrationEngine.ts"; |
| 48 | import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts"; | 48 | import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts"; |
| 49 | import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts"; | 49 | import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts"; |
| ... | @@ -335,11 +335,11 @@ describe("ProviderCommandReactor", () => { | ... | @@ -335,11 +335,11 @@ describe("ProviderCommandReactor", () => { |
| 335 | Layer.provide(OrchestrationProjectionPipelineLive), | 335 | Layer.provide(OrchestrationProjectionPipelineLive), |
| 336 | Layer.provide(OrchestrationEventStoreLive), | 336 | Layer.provide(OrchestrationEventStoreLive), |
| 337 | Layer.provide(OrchestrationCommandReceiptRepositoryLive), | 337 | Layer.provide(OrchestrationCommandReceiptRepositoryLive), |
| 338 | Layer.provide(RepositoryIdentityResolverLive), | 338 | Layer.provide(RepositoryIdentityResolver.layer), |
| 339 | Layer.provide(SqlitePersistenceMemory), | 339 | Layer.provide(SqlitePersistenceMemory), |
| 340 | ); | 340 | ); |
| 341 | const projectionSnapshotLayer = OrchestrationProjectionSnapshotQueryLive.pipe( | 341 | const projectionSnapshotLayer = OrchestrationProjectionSnapshotQueryLive.pipe( |
| 342 | Layer.provide(RepositoryIdentityResolverLive), | 342 | Layer.provide(RepositoryIdentityResolver.layer), |
| 343 | Layer.provide(SqlitePersistenceMemory), | 343 | Layer.provide(SqlitePersistenceMemory), |
| 344 | ); | 344 | ); |
| 345 | const layer = ProviderCommandReactorLive.pipe( | 345 | const layer = ProviderCommandReactorLive.pipe( |
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts+3-3| ... | @@ -39,7 +39,7 @@ import { | ... | @@ -39,7 +39,7 @@ import { |
| 39 | ProviderService, | 39 | ProviderService, |
| 40 | type ProviderServiceShape, | 40 | type ProviderServiceShape, |
| 41 | } from "../../provider/Services/ProviderService.ts"; | 41 | } from "../../provider/Services/ProviderService.ts"; |
| 42 | import { RepositoryIdentityResolverLive } from "../../project/Layers/RepositoryIdentityResolver.ts"; | 42 | import * as RepositoryIdentityResolver from "../../project/RepositoryIdentityResolver.ts"; |
| 43 | import { OrchestrationEngineLive } from "./OrchestrationEngine.ts"; | 43 | import { OrchestrationEngineLive } from "./OrchestrationEngine.ts"; |
| 44 | import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts"; | 44 | import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts"; |
| 45 | import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts"; | 45 | import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts"; |
| ... | @@ -226,11 +226,11 @@ describe("ProviderRuntimeIngestion", () => { | ... | @@ -226,11 +226,11 @@ describe("ProviderRuntimeIngestion", () => { |
| 226 | Layer.provide(OrchestrationProjectionPipelineLive), | 226 | Layer.provide(OrchestrationProjectionPipelineLive), |
| 227 | Layer.provide(OrchestrationEventStoreLive), | 227 | Layer.provide(OrchestrationEventStoreLive), |
| 228 | Layer.provide(OrchestrationCommandReceiptRepositoryLive), | 228 | Layer.provide(OrchestrationCommandReceiptRepositoryLive), |
| 229 | Layer.provide(RepositoryIdentityResolverLive), | 229 | Layer.provide(RepositoryIdentityResolver.layer), |
| 230 | Layer.provide(SqlitePersistenceMemory), | 230 | Layer.provide(SqlitePersistenceMemory), |
| 231 | ); | 231 | ); |
| 232 | const projectionSnapshotLayer = OrchestrationProjectionSnapshotQueryLive.pipe( | 232 | const projectionSnapshotLayer = OrchestrationProjectionSnapshotQueryLive.pipe( |
| 233 | Layer.provide(RepositoryIdentityResolverLive), | 233 | Layer.provide(RepositoryIdentityResolver.layer), |
| 234 | Layer.provide(SqlitePersistenceMemory), | 234 | Layer.provide(SqlitePersistenceMemory), |
| 235 | ); | 235 | ); |
| 236 | const layer = ProviderRuntimeIngestionLive.pipe( | 236 | const layer = ProviderRuntimeIngestionLive.pipe( |
apps/server/src/orchestration/Normalizer.ts+2-2| ... | @@ -11,14 +11,14 @@ import { | ... | @@ -11,14 +11,14 @@ import { |
| 11 | import { createAttachmentId, resolveAttachmentPath } from "../attachmentStore.ts"; | 11 | import { createAttachmentId, resolveAttachmentPath } from "../attachmentStore.ts"; |
| 12 | import { ServerConfig } from "../config.ts"; | 12 | import { ServerConfig } from "../config.ts"; |
| 13 | import { parseBase64DataUrl } from "../imageMime.ts"; | 13 | import { parseBase64DataUrl } from "../imageMime.ts"; |
| 14 | import { WorkspacePaths } from "../workspace/Services/WorkspacePaths.ts"; | 14 | import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; |
| 15 | 15 | ||
| 16 | export const normalizeDispatchCommand = (command: ClientOrchestrationCommand) => | 16 | export const normalizeDispatchCommand = (command: ClientOrchestrationCommand) => |
| 17 | Effect.gen(function* () { | 17 | Effect.gen(function* () { |
| 18 | const fileSystem = yield* FileSystem.FileSystem; | 18 | const fileSystem = yield* FileSystem.FileSystem; |
| 19 | const path = yield* Path.Path; | 19 | const path = yield* Path.Path; |
| 20 | const serverConfig = yield* ServerConfig; | 20 | const serverConfig = yield* ServerConfig; |
| 21 | const workspacePaths = yield* WorkspacePaths; | 21 | const workspacePaths = yield* WorkspacePaths.WorkspacePaths; |
| 22 | 22 | ||
| 23 | const normalizeProjectWorkspaceRoot = (workspaceRoot: string) => | 23 | const normalizeProjectWorkspaceRoot = (workspaceRoot: string) => |
| 24 | workspacePaths.normalizeWorkspaceRoot(workspaceRoot).pipe( | 24 | workspacePaths.normalizeWorkspaceRoot(workspaceRoot).pipe( |
apps/server/src/project/Layers/ProjectFaviconResolver.test.ts deleted-78| ... | @@ -1,78 +0,0 @@ | ||
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| 2 | import { it, describe, expect } from "@effect/vitest"; | ||
| 3 | import * as Effect from "effect/Effect"; | ||
| 4 | import * as FileSystem from "effect/FileSystem"; | ||
| 5 | import * as Layer from "effect/Layer"; | ||
| 6 | import * as Path from "effect/Path"; | ||
| 7 | |||
| 8 | import { ProjectFaviconResolver } from "../Services/ProjectFaviconResolver.ts"; | ||
| 9 | import { ProjectFaviconResolverLive } from "./ProjectFaviconResolver.ts"; | ||
| 10 | import { WorkspacePathsLive } from "../../workspace/Layers/WorkspacePaths.ts"; | ||
| 11 | |||
| 12 | const TestLayer = Layer.empty.pipe( | ||
| 13 | Layer.provideMerge(ProjectFaviconResolverLive.pipe(Layer.provide(WorkspacePathsLive))), | ||
| 14 | Layer.provideMerge(NodeServices.layer), | ||
| 15 | ); | ||
| 16 | |||
| 17 | const makeTempDir = Effect.gen(function* () { | ||
| 18 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 19 | return yield* fileSystem.makeTempDirectoryScoped({ | ||
| 20 | prefix: "t3code-project-favicon-", | ||
| 21 | }); | ||
| 22 | }); | ||
| 23 | |||
| 24 | const writeTextFile = Effect.fn("writeTextFile")(function* ( | ||
| 25 | cwd: string, | ||
| 26 | relativePath: string, | ||
| 27 | contents: string, | ||
| 28 | ) { | ||
| 29 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 30 | const path = yield* Path.Path; | ||
| 31 | const absolutePath = path.join(cwd, relativePath); | ||
| 32 | yield* fileSystem | ||
| 33 | .makeDirectory(path.dirname(absolutePath), { recursive: true }) | ||
| 34 | .pipe(Effect.orDie); | ||
| 35 | yield* fileSystem.writeFileString(absolutePath, contents).pipe(Effect.orDie); | ||
| 36 | }); | ||
| 37 | |||
| 38 | it.layer(TestLayer)("ProjectFaviconResolverLive", (it) => { | ||
| 39 | describe("resolvePath", () => { | ||
| 40 | it.effect("prefers well-known favicon files", () => | ||
| 41 | Effect.gen(function* () { | ||
| 42 | const resolver = yield* ProjectFaviconResolver; | ||
| 43 | const cwd = yield* makeTempDir; | ||
| 44 | yield* writeTextFile(cwd, "favicon.svg", "<svg>favicon</svg>"); | ||
| 45 | |||
| 46 | const resolved = yield* resolver.resolvePath(cwd); | ||
| 47 | |||
| 48 | expect(resolved).not.toBeNull(); | ||
| 49 | expect(resolved).toContain("favicon.svg"); | ||
| 50 | }), | ||
| 51 | ); | ||
| 52 | |||
| 53 | it.effect("resolves icon hrefs from project source files", () => | ||
| 54 | Effect.gen(function* () { | ||
| 55 | const resolver = yield* ProjectFaviconResolver; | ||
| 56 | const cwd = yield* makeTempDir; | ||
| 57 | yield* writeTextFile(cwd, "index.html", '<link rel="icon" href="/brand/logo.svg">'); | ||
| 58 | yield* writeTextFile(cwd, "public/brand/logo.svg", "<svg>brand</svg>"); | ||
| 59 | |||
| 60 | const resolved = yield* resolver.resolvePath(cwd); | ||
| 61 | |||
| 62 | expect(resolved).not.toBeNull(); | ||
| 63 | expect(resolved).toContain("public/brand/logo.svg"); | ||
| 64 | }), | ||
| 65 | ); | ||
| 66 | |||
| 67 | it.effect("returns null when no icon is present", () => | ||
| 68 | Effect.gen(function* () { | ||
| 69 | const resolver = yield* ProjectFaviconResolver; | ||
| 70 | const cwd = yield* makeTempDir; | ||
| 71 | |||
| 72 | const resolved = yield* resolver.resolvePath(cwd); | ||
| 73 | |||
| 74 | expect(resolved).toBeNull(); | ||
| 75 | }), | ||
| 76 | ); | ||
| 77 | }); | ||
| 78 | }); | ||
apps/server/src/project/Layers/ProjectFaviconResolver.ts deleted-149| ... | @@ -1,149 +0,0 @@ | ||
| 1 | import * as Effect from "effect/Effect"; | ||
| 2 | import * as FileSystem from "effect/FileSystem"; | ||
| 3 | import * as Layer from "effect/Layer"; | ||
| 4 | import * as Path from "effect/Path"; | ||
| 5 | |||
| 6 | import { | ||
| 7 | ProjectFaviconResolver, | ||
| 8 | type ProjectFaviconResolverShape, | ||
| 9 | } from "../Services/ProjectFaviconResolver.ts"; | ||
| 10 | import { WorkspacePaths } from "../../workspace/Services/WorkspacePaths.ts"; | ||
| 11 | |||
| 12 | // Well-known favicon paths checked in order. | ||
| 13 | const FAVICON_CANDIDATES = [ | ||
| 14 | "favicon.svg", | ||
| 15 | "favicon.ico", | ||
| 16 | "favicon.png", | ||
| 17 | "public/favicon.svg", | ||
| 18 | "public/favicon.ico", | ||
| 19 | "public/favicon.png", | ||
| 20 | "app/favicon.ico", | ||
| 21 | "app/favicon.png", | ||
| 22 | "app/icon.svg", | ||
| 23 | "app/icon.png", | ||
| 24 | "app/icon.ico", | ||
| 25 | "src/favicon.ico", | ||
| 26 | "src/favicon.svg", | ||
| 27 | "src/app/favicon.ico", | ||
| 28 | "src/app/icon.svg", | ||
| 29 | "src/app/icon.png", | ||
| 30 | "assets/icon.svg", | ||
| 31 | "assets/icon.png", | ||
| 32 | "assets/logo.svg", | ||
| 33 | "assets/logo.png", | ||
| 34 | ".idea/icon.svg", | ||
| 35 | ] as const; | ||
| 36 | |||
| 37 | // Files that may contain a <link rel="icon"> or icon metadata declaration. | ||
| 38 | const ICON_SOURCE_FILES = [ | ||
| 39 | "index.html", | ||
| 40 | "public/index.html", | ||
| 41 | "app/routes/__root.tsx", | ||
| 42 | "src/routes/__root.tsx", | ||
| 43 | "app/root.tsx", | ||
| 44 | "src/root.tsx", | ||
| 45 | "src/index.html", | ||
| 46 | ] as const; | ||
| 47 | |||
| 48 | // Matches <link ...> tags or object-like icon metadata where rel/href can appear in any order. | ||
| 49 | const LINK_ICON_HTML_RE = | ||
| 50 | /<link\b(?=[^>]*\brel=["'](?:icon|shortcut icon)["'])(?=[^>]*\bhref=["']([^"'?]+))[^>]*>/i; | ||
| 51 | const LINK_ICON_OBJ_RE = | ||
| 52 | /(?=[^}]*\brel\s*:\s*["'](?:icon|shortcut icon)["'])(?=[^}]*\bhref\s*:\s*["']([^"'?]+))[^}]*/i; | ||
| 53 | |||
| 54 | function extractIconHref(source: string): string | null { | ||
| 55 | const htmlMatch = source.match(LINK_ICON_HTML_RE); | ||
| 56 | if (htmlMatch?.[1]) return htmlMatch[1]; | ||
| 57 | const objMatch = source.match(LINK_ICON_OBJ_RE); | ||
| 58 | if (objMatch?.[1]) return objMatch[1]; | ||
| 59 | return null; | ||
| 60 | } | ||
| 61 | |||
| 62 | export const makeProjectFaviconResolver = Effect.gen(function* () { | ||
| 63 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 64 | const path = yield* Path.Path; | ||
| 65 | const workspacePaths = yield* WorkspacePaths; | ||
| 66 | |||
| 67 | const resolveIconHref = (href: string): string[] => { | ||
| 68 | const clean = href.replace(/^\//, ""); | ||
| 69 | return [path.join("public", clean), clean]; | ||
| 70 | }; | ||
| 71 | |||
| 72 | const findExistingFile = Effect.fn("ProjectFaviconResolver.findExistingFile")(function* ( | ||
| 73 | projectCwd: string, | ||
| 74 | relativeCandidates: ReadonlyArray<string>, | ||
| 75 | ): Effect.fn.Return<string | null> { | ||
| 76 | for (const relativePath of relativeCandidates) { | ||
| 77 | const candidate = yield* workspacePaths | ||
| 78 | .resolveRelativePathWithinRoot({ | ||
| 79 | workspaceRoot: projectCwd, | ||
| 80 | relativePath, | ||
| 81 | }) | ||
| 82 | .pipe(Effect.orElseSucceed(() => null)); | ||
| 83 | if (!candidate) { | ||
| 84 | continue; | ||
| 85 | } | ||
| 86 | const stats = yield* fileSystem | ||
| 87 | .stat(candidate.absolutePath) | ||
| 88 | .pipe(Effect.orElseSucceed(() => null)); | ||
| 89 | if (stats?.type === "File") { | ||
| 90 | return candidate.absolutePath; | ||
| 91 | } | ||
| 92 | } | ||
| 93 | return null; | ||
| 94 | }); | ||
| 95 | |||
| 96 | const resolvePath: ProjectFaviconResolverShape["resolvePath"] = Effect.fn( | ||
| 97 | "ProjectFaviconResolver.resolvePath", | ||
| 98 | )(function* (cwd: string): Effect.fn.Return<string | null> { | ||
| 99 | const projectCwd = yield* workspacePaths | ||
| 100 | .normalizeWorkspaceRoot(cwd) | ||
| 101 | .pipe(Effect.orElseSucceed(() => null)); | ||
| 102 | if (!projectCwd) { | ||
| 103 | return null; | ||
| 104 | } | ||
| 105 | for (const candidate of FAVICON_CANDIDATES) { | ||
| 106 | const existing = yield* findExistingFile(projectCwd, [candidate]); | ||
| 107 | if (existing) { | ||
| 108 | return existing; | ||
| 109 | } | ||
| 110 | } | ||
| 111 | |||
| 112 | for (const sourceFile of ICON_SOURCE_FILES) { | ||
| 113 | const sourcePath = yield* workspacePaths | ||
| 114 | .resolveRelativePathWithinRoot({ | ||
| 115 | workspaceRoot: projectCwd, | ||
| 116 | relativePath: sourceFile, | ||
| 117 | }) | ||
| 118 | .pipe(Effect.orElseSucceed(() => null)); | ||
| 119 | if (!sourcePath) { | ||
| 120 | continue; | ||
| 121 | } | ||
| 122 | const source = yield* fileSystem | ||
| 123 | .readFileString(sourcePath.absolutePath) | ||
| 124 | .pipe(Effect.orElseSucceed(() => null)); | ||
| 125 | if (!source) { | ||
| 126 | continue; | ||
| 127 | } | ||
| 128 | const href = extractIconHref(source); | ||
| 129 | if (!href) { | ||
| 130 | continue; | ||
| 131 | } | ||
| 132 | const existing = yield* findExistingFile(projectCwd, resolveIconHref(href)); | ||
| 133 | if (existing) { | ||
| 134 | return existing; | ||
| 135 | } | ||
| 136 | } | ||
| 137 | |||
| 138 | return null; | ||
| 139 | }); | ||
| 140 | |||
| 141 | return { | ||
| 142 | resolvePath, | ||
| 143 | } satisfies ProjectFaviconResolverShape; | ||
| 144 | }); | ||
| 145 | |||
| 146 | export const ProjectFaviconResolverLive = Layer.effect( | ||
| 147 | ProjectFaviconResolver, | ||
| 148 | makeProjectFaviconResolver, | ||
| 149 | ); | ||
apps/server/src/project/Layers/ProjectSetupScriptRunner.test.ts deleted-165| ... | @@ -1,165 +0,0 @@ | ||
| 1 | import { ProjectId, type OrchestrationProject } from "@t3tools/contracts"; | ||
| 2 | import * as Effect from "effect/Effect"; | ||
| 3 | import * as Layer from "effect/Layer"; | ||
| 4 | import * as Option from "effect/Option"; | ||
| 5 | import { describe, expect, it, vi } from "vite-plus/test"; | ||
| 6 | |||
| 7 | import { ProjectionSnapshotQuery } from "../../orchestration/Services/ProjectionSnapshotQuery.ts"; | ||
| 8 | import * as TerminalManager from "../../terminal/Manager.ts"; | ||
| 9 | import { ProjectSetupScriptRunner } from "../Services/ProjectSetupScriptRunner.ts"; | ||
| 10 | import { ProjectSetupScriptRunnerLive } from "./ProjectSetupScriptRunner.ts"; | ||
| 11 | |||
| 12 | const makeProject = (scripts: OrchestrationProject["scripts"]): OrchestrationProject => ({ | ||
| 13 | id: ProjectId.make("project-1"), | ||
| 14 | title: "Project", | ||
| 15 | workspaceRoot: "/repo/project", | ||
| 16 | defaultModelSelection: null, | ||
| 17 | scripts, | ||
| 18 | createdAt: "2026-01-01T00:00:00.000Z", | ||
| 19 | updatedAt: "2026-01-01T00:00:00.000Z", | ||
| 20 | deletedAt: null, | ||
| 21 | }); | ||
| 22 | |||
| 23 | const makeProjectionSnapshotQueryLayer = (project: OrchestrationProject) => | ||
| 24 | Layer.succeed(ProjectionSnapshotQuery, { | ||
| 25 | getCommandReadModel: () => Effect.die("unused"), | ||
| 26 | getSnapshot: () => Effect.die("unused"), | ||
| 27 | getShellSnapshot: () => Effect.die("unused"), | ||
| 28 | getArchivedShellSnapshot: () => Effect.die("unused"), | ||
| 29 | getSnapshotSequence: () => Effect.succeed({ snapshotSequence: 1 }), | ||
| 30 | getCounts: () => Effect.die("unused"), | ||
| 31 | getActiveProjectByWorkspaceRoot: (workspaceRoot) => | ||
| 32 | Effect.succeed( | ||
| 33 | workspaceRoot === project.workspaceRoot ? Option.some(project) : Option.none(), | ||
| 34 | ), | ||
| 35 | getProjectShellById: (projectId) => | ||
| 36 | Effect.succeed(projectId === project.id ? Option.some(project) : Option.none()), | ||
| 37 | getFirstActiveThreadIdByProjectId: () => Effect.die("unused"), | ||
| 38 | getThreadCheckpointContext: () => Effect.die("unused"), | ||
| 39 | getFullThreadDiffContext: () => Effect.die("unused"), | ||
| 40 | getThreadShellById: () => Effect.die("unused"), | ||
| 41 | getThreadDetailById: () => Effect.die("unused"), | ||
| 42 | }); | ||
| 43 | |||
| 44 | describe("ProjectSetupScriptRunner", () => { | ||
| 45 | it("returns no-script when no setup script exists", async () => { | ||
| 46 | const open = vi.fn(); | ||
| 47 | const write = vi.fn(); | ||
| 48 | const project = makeProject([]); | ||
| 49 | const runner = await Effect.runPromise( | ||
| 50 | Effect.service(ProjectSetupScriptRunner).pipe( | ||
| 51 | Effect.provide( | ||
| 52 | ProjectSetupScriptRunnerLive.pipe( | ||
| 53 | Layer.provideMerge(makeProjectionSnapshotQueryLayer(project)), | ||
| 54 | Layer.provideMerge( | ||
| 55 | Layer.succeed(TerminalManager.TerminalManager, { | ||
| 56 | open, | ||
| 57 | attachStream: () => Effect.die(new Error("unused")), | ||
| 58 | write, | ||
| 59 | resize: () => Effect.void, | ||
| 60 | clear: () => Effect.void, | ||
| 61 | restart: () => Effect.die(new Error("unused")), | ||
| 62 | close: () => Effect.void, | ||
| 63 | subscribe: () => Effect.succeed(() => undefined), | ||
| 64 | subscribeMetadata: () => Effect.succeed(() => undefined), | ||
| 65 | }), | ||
| 66 | ), | ||
| 67 | ), | ||
| 68 | ), | ||
| 69 | ), | ||
| 70 | ); | ||
| 71 | |||
| 72 | const result = await Effect.runPromise( | ||
| 73 | runner.runForThread({ | ||
| 74 | threadId: "thread-1", | ||
| 75 | projectId: "project-1", | ||
| 76 | worktreePath: "/repo/worktrees/a", | ||
| 77 | }), | ||
| 78 | ); | ||
| 79 | |||
| 80 | expect(result).toEqual({ status: "no-script" }); | ||
| 81 | expect(open).not.toHaveBeenCalled(); | ||
| 82 | expect(write).not.toHaveBeenCalled(); | ||
| 83 | }); | ||
| 84 | |||
| 85 | it("opens the deterministic setup terminal with worktree env and writes the command", async () => { | ||
| 86 | const open = vi.fn(() => | ||
| 87 | Effect.succeed({ | ||
| 88 | threadId: "thread-1", | ||
| 89 | terminalId: "setup-setup", | ||
| 90 | cwd: "/repo/worktrees/a", | ||
| 91 | worktreePath: "/repo/worktrees/a", | ||
| 92 | status: "running" as const, | ||
| 93 | pid: 123, | ||
| 94 | history: "", | ||
| 95 | exitCode: null, | ||
| 96 | exitSignal: null, | ||
| 97 | label: "setup-setup", | ||
| 98 | updatedAt: "2026-01-01T00:00:00.000Z", | ||
| 99 | }), | ||
| 100 | ); | ||
| 101 | const write = vi.fn(() => Effect.void); | ||
| 102 | const project = makeProject([ | ||
| 103 | { | ||
| 104 | id: "setup", | ||
| 105 | name: "Setup", | ||
| 106 | command: "bun install", | ||
| 107 | icon: "configure", | ||
| 108 | runOnWorktreeCreate: true, | ||
| 109 | }, | ||
| 110 | ]); | ||
| 111 | const runner = await Effect.runPromise( | ||
| 112 | Effect.service(ProjectSetupScriptRunner).pipe( | ||
| 113 | Effect.provide( | ||
| 114 | ProjectSetupScriptRunnerLive.pipe( | ||
| 115 | Layer.provideMerge(makeProjectionSnapshotQueryLayer(project)), | ||
| 116 | Layer.provideMerge( | ||
| 117 | Layer.succeed(TerminalManager.TerminalManager, { | ||
| 118 | open, | ||
| 119 | attachStream: () => Effect.die(new Error("unused")), | ||
| 120 | write, | ||
| 121 | resize: () => Effect.void, | ||
| 122 | clear: () => Effect.void, | ||
| 123 | restart: () => Effect.die(new Error("unused")), | ||
| 124 | close: () => Effect.void, | ||
| 125 | subscribe: () => Effect.succeed(() => undefined), | ||
| 126 | subscribeMetadata: () => Effect.succeed(() => undefined), | ||
| 127 | }), | ||
| 128 | ), | ||
| 129 | ), | ||
| 130 | ), | ||
| 131 | ), | ||
| 132 | ); | ||
| 133 | |||
| 134 | const result = await Effect.runPromise( | ||
| 135 | runner.runForThread({ | ||
| 136 | threadId: "thread-1", | ||
| 137 | projectCwd: "/repo/project", | ||
| 138 | worktreePath: "/repo/worktrees/a", | ||
| 139 | }), | ||
| 140 | ); | ||
| 141 | |||
| 142 | expect(result).toEqual({ | ||
| 143 | status: "started", | ||
| 144 | scriptId: "setup", | ||
| 145 | scriptName: "Setup", | ||
| 146 | terminalId: "setup-setup", | ||
| 147 | cwd: "/repo/worktrees/a", | ||
| 148 | }); | ||
| 149 | expect(open).toHaveBeenCalledWith({ | ||
| 150 | threadId: "thread-1", | ||
| 151 | terminalId: "setup-setup", | ||
| 152 | cwd: "/repo/worktrees/a", | ||
| 153 | worktreePath: "/repo/worktrees/a", | ||
| 154 | env: { | ||
| 155 | T3CODE_PROJECT_ROOT: "/repo/project", | ||
| 156 | T3CODE_WORKTREE_PATH: "/repo/worktrees/a", | ||
| 157 | }, | ||
| 158 | }); | ||
| 159 | expect(write).toHaveBeenCalledWith({ | ||
| 160 | threadId: "thread-1", | ||
| 161 | terminalId: "setup-setup", | ||
| 162 | data: "bun install\r", | ||
| 163 | }); | ||
| 164 | }); | ||
| 165 | }); | ||
apps/server/src/project/Layers/ProjectSetupScriptRunner.ts deleted-103| ... | @@ -1,103 +0,0 @@ | ||
| 1 | import { ProjectId } from "@t3tools/contracts"; | ||
| 2 | import { projectScriptRuntimeEnv, setupProjectScript } from "@t3tools/shared/projectScripts"; | ||
| 3 | import * as Effect from "effect/Effect"; | ||
| 4 | import * as Layer from "effect/Layer"; | ||
| 5 | import * as Option from "effect/Option"; | ||
| 6 | |||
| 7 | import { ProjectionSnapshotQuery } from "../../orchestration/Services/ProjectionSnapshotQuery.ts"; | ||
| 8 | import * as TerminalManager from "../../terminal/Manager.ts"; | ||
| 9 | import { | ||
| 10 | type ProjectSetupScriptRunnerShape, | ||
| 11 | ProjectSetupScriptRunner, | ||
| 12 | ProjectSetupScriptRunnerError, | ||
| 13 | } from "../Services/ProjectSetupScriptRunner.ts"; | ||
| 14 | |||
| 15 | const makeProjectSetupScriptRunner = Effect.gen(function* () { | ||
| 16 | const projectionSnapshotQuery = yield* ProjectionSnapshotQuery; | ||
| 17 | const terminalManager = yield* TerminalManager.TerminalManager; | ||
| 18 | |||
| 19 | const runForThread: ProjectSetupScriptRunnerShape["runForThread"] = (input) => | ||
| 20 | Effect.gen(function* () { | ||
| 21 | const project = | ||
| 22 | (input.projectId | ||
| 23 | ? yield* projectionSnapshotQuery | ||
| 24 | .getProjectShellById(ProjectId.make(input.projectId)) | ||
| 25 | .pipe(Effect.map(Option.getOrUndefined)) | ||
| 26 | : null) ?? | ||
| 27 | (input.projectCwd | ||
| 28 | ? yield* projectionSnapshotQuery | ||
| 29 | .getActiveProjectByWorkspaceRoot(input.projectCwd) | ||
| 30 | .pipe(Effect.map(Option.getOrUndefined)) | ||
| 31 | : null) ?? | ||
| 32 | null; | ||
| 33 | |||
| 34 | if (!project) { | ||
| 35 | return yield* new ProjectSetupScriptRunnerError({ | ||
| 36 | message: "Project was not found for setup script execution.", | ||
| 37 | }); | ||
| 38 | } | ||
| 39 | |||
| 40 | const script = setupProjectScript(project.scripts); | ||
| 41 | if (!script) { | ||
| 42 | return { | ||
| 43 | status: "no-script", | ||
| 44 | } as const; | ||
| 45 | } | ||
| 46 | |||
| 47 | const terminalId = input.preferredTerminalId ?? `setup-${script.id}`; | ||
| 48 | const cwd = input.worktreePath; | ||
| 49 | const env = projectScriptRuntimeEnv({ | ||
| 50 | project: { cwd: project.workspaceRoot }, | ||
| 51 | worktreePath: input.worktreePath, | ||
| 52 | }); | ||
| 53 | |||
| 54 | yield* terminalManager.open({ | ||
| 55 | threadId: input.threadId, | ||
| 56 | terminalId, | ||
| 57 | cwd, | ||
| 58 | worktreePath: input.worktreePath, | ||
| 59 | env, | ||
| 60 | }); | ||
| 61 | yield* terminalManager.write({ | ||
| 62 | threadId: input.threadId, | ||
| 63 | terminalId, | ||
| 64 | data: `${script.command}\r`, | ||
| 65 | }); | ||
| 66 | |||
| 67 | return { | ||
| 68 | status: "started", | ||
| 69 | scriptId: script.id, | ||
| 70 | scriptName: script.name, | ||
| 71 | terminalId, | ||
| 72 | cwd, | ||
| 73 | } as const; | ||
| 74 | }).pipe( | ||
| 75 | Effect.mapError((cause) => { | ||
| 76 | if ( | ||
| 77 | typeof cause === "object" && | ||
| 78 | cause !== null && | ||
| 79 | "_tag" in cause && | ||
| 80 | cause._tag === "ProjectSetupScriptRunnerError" | ||
| 81 | ) { | ||
| 82 | return cause as ProjectSetupScriptRunnerError; | ||
| 83 | } | ||
| 84 | const message = | ||
| 85 | typeof cause === "object" && | ||
| 86 | cause !== null && | ||
| 87 | "message" in cause && | ||
| 88 | typeof cause.message === "string" | ||
| 89 | ? cause.message | ||
| 90 | : String(cause); | ||
| 91 | return new ProjectSetupScriptRunnerError({ message }); | ||
| 92 | }), | ||
| 93 | ); | ||
| 94 | |||
| 95 | return { | ||
| 96 | runForThread, | ||
| 97 | } satisfies ProjectSetupScriptRunnerShape; | ||
| 98 | }); | ||
| 99 | |||
| 100 | export const ProjectSetupScriptRunnerLive = Layer.effect( | ||
| 101 | ProjectSetupScriptRunner, | ||
| 102 | makeProjectSetupScriptRunner, | ||
| 103 | ); | ||
apps/server/src/project/Layers/RepositoryIdentityResolver.test.ts deleted-239| ... | @@ -1,239 +0,0 @@ | ||
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| 2 | import { expect, it } from "@effect/vitest"; | ||
| 3 | import * as Duration from "effect/Duration"; | ||
| 4 | import * as Effect from "effect/Effect"; | ||
| 5 | import * as FileSystem from "effect/FileSystem"; | ||
| 6 | import * as Layer from "effect/Layer"; | ||
| 7 | import * as Path from "effect/Path"; | ||
| 8 | import { TestClock } from "effect/testing"; | ||
| 9 | |||
| 10 | import * as ProcessRunner from "../../processRunner.ts"; | ||
| 11 | import { RepositoryIdentityResolver } from "../Services/RepositoryIdentityResolver.ts"; | ||
| 12 | import { | ||
| 13 | makeRepositoryIdentityResolver, | ||
| 14 | RepositoryIdentityResolverLive, | ||
| 15 | } from "./RepositoryIdentityResolver.ts"; | ||
| 16 | |||
| 17 | const normalizePathSeparators = (value: string) => value.replaceAll("\\", "/"); | ||
| 18 | const normalizeResolvedPath = (value: string) => normalizePathSeparators(value); | ||
| 19 | |||
| 20 | const git = (cwd: string, args: ReadonlyArray<string>) => | ||
| 21 | Effect.gen(function* () { | ||
| 22 | const processRunner = yield* ProcessRunner.ProcessRunner; | ||
| 23 | return yield* processRunner.run({ | ||
| 24 | command: "git", | ||
| 25 | args: ["-C", cwd, ...args], | ||
| 26 | }); | ||
| 27 | }).pipe(Effect.provide(ProcessRunner.layer)); | ||
| 28 | |||
| 29 | const makeRepositoryIdentityResolverTestLayer = (options: { | ||
| 30 | readonly positiveCacheTtl?: Duration.Input; | ||
| 31 | readonly negativeCacheTtl?: Duration.Input; | ||
| 32 | }) => | ||
| 33 | Layer.effect( | ||
| 34 | RepositoryIdentityResolver, | ||
| 35 | makeRepositoryIdentityResolver({ | ||
| 36 | cacheCapacity: 16, | ||
| 37 | ...options, | ||
| 38 | }), | ||
| 39 | ).pipe(Layer.provide(ProcessRunner.layer)); | ||
| 40 | |||
| 41 | it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => { | ||
| 42 | it.effect("normalizes equivalent GitHub remotes into a stable repository identity", () => | ||
| 43 | Effect.gen(function* () { | ||
| 44 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 45 | const cwd = yield* fileSystem.makeTempDirectoryScoped({ | ||
| 46 | prefix: "t3-repository-identity-test-", | ||
| 47 | }); | ||
| 48 | |||
| 49 | yield* git(cwd, ["init"]); | ||
| 50 | yield* git(cwd, ["remote", "add", "origin", "git@github.com:T3Tools/t3code.git"]); | ||
| 51 | |||
| 52 | const resolver = yield* RepositoryIdentityResolver; | ||
| 53 | const identity = yield* resolver.resolve(cwd); | ||
| 54 | const resolvedIdentityRoot = | ||
| 55 | identity?.rootPath === undefined ? "" : yield* fileSystem.realPath(identity.rootPath); | ||
| 56 | const resolvedCwd = yield* fileSystem.realPath(cwd); | ||
| 57 | |||
| 58 | expect(identity).not.toBeNull(); | ||
| 59 | expect(identity?.canonicalKey).toBe("github.com/t3tools/t3code"); | ||
| 60 | expect(normalizeResolvedPath(resolvedIdentityRoot)).toBe(normalizeResolvedPath(resolvedCwd)); | ||
| 61 | expect(identity?.displayName).toBe("t3tools/t3code"); | ||
| 62 | expect(identity?.provider).toBe("github"); | ||
| 63 | expect(identity?.owner).toBe("t3tools"); | ||
| 64 | expect(identity?.name).toBe("t3code"); | ||
| 65 | }).pipe(Effect.provide(RepositoryIdentityResolverLive)), | ||
| 66 | ); | ||
| 67 | |||
| 68 | it.effect("returns the git top-level root path when resolving from a nested workspace", () => | ||
| 69 | Effect.gen(function* () { | ||
| 70 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 71 | const path = yield* Path.Path; | ||
| 72 | const repoRoot = yield* fileSystem.makeTempDirectoryScoped({ | ||
| 73 | prefix: "t3-repository-identity-nested-root-test-", | ||
| 74 | }); | ||
| 75 | const nestedWorkspace = path.join(repoRoot, "packages", "web"); | ||
| 76 | |||
| 77 | yield* fileSystem.makeDirectory(nestedWorkspace, { recursive: true }); | ||
| 78 | yield* git(repoRoot, ["init"]); | ||
| 79 | yield* git(repoRoot, ["remote", "add", "origin", "git@github.com:T3Tools/t3code.git"]); | ||
| 80 | |||
| 81 | const resolver = yield* RepositoryIdentityResolver; | ||
| 82 | const identity = yield* resolver.resolve(nestedWorkspace); | ||
| 83 | const resolvedIdentityRoot = | ||
| 84 | identity?.rootPath === undefined ? "" : yield* fileSystem.realPath(identity.rootPath); | ||
| 85 | const resolvedRepoRoot = yield* fileSystem.realPath(repoRoot); | ||
| 86 | |||
| 87 | expect(identity).not.toBeNull(); | ||
| 88 | expect(identity?.canonicalKey).toBe("github.com/t3tools/t3code"); | ||
| 89 | expect(normalizeResolvedPath(resolvedIdentityRoot)).toBe( | ||
| 90 | normalizeResolvedPath(resolvedRepoRoot), | ||
| 91 | ); | ||
| 92 | }).pipe(Effect.provide(RepositoryIdentityResolverLive)), | ||
| 93 | ); | ||
| 94 | |||
| 95 | it.effect("returns null for non-git folders and repos without remotes", () => | ||
| 96 | Effect.gen(function* () { | ||
| 97 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 98 | const nonGitDir = yield* fileSystem.makeTempDirectoryScoped({ | ||
| 99 | prefix: "t3-repository-identity-non-git-", | ||
| 100 | }); | ||
| 101 | const gitDir = yield* fileSystem.makeTempDirectoryScoped({ | ||
| 102 | prefix: "t3-repository-identity-no-remote-", | ||
| 103 | }); | ||
| 104 | |||
| 105 | yield* git(gitDir, ["init"]); | ||
| 106 | |||
| 107 | const resolver = yield* RepositoryIdentityResolver; | ||
| 108 | const nonGitIdentity = yield* resolver.resolve(nonGitDir); | ||
| 109 | const noRemoteIdentity = yield* resolver.resolve(gitDir); | ||
| 110 | |||
| 111 | expect(nonGitIdentity).toBeNull(); | ||
| 112 | expect(noRemoteIdentity).toBeNull(); | ||
| 113 | }).pipe(Effect.provide(RepositoryIdentityResolverLive)), | ||
| 114 | ); | ||
| 115 | |||
| 116 | it.effect("prefers upstream over origin when both remotes are configured", () => | ||
| 117 | Effect.gen(function* () { | ||
| 118 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 119 | const cwd = yield* fileSystem.makeTempDirectoryScoped({ | ||
| 120 | prefix: "t3-repository-identity-upstream-test-", | ||
| 121 | }); | ||
| 122 | |||
| 123 | yield* git(cwd, ["init"]); | ||
| 124 | yield* git(cwd, ["remote", "add", "origin", "git@github.com:julius/t3code.git"]); | ||
| 125 | yield* git(cwd, ["remote", "add", "upstream", "git@github.com:T3Tools/t3code.git"]); | ||
| 126 | |||
| 127 | const resolver = yield* RepositoryIdentityResolver; | ||
| 128 | const identity = yield* resolver.resolve(cwd); | ||
| 129 | |||
| 130 | expect(identity).not.toBeNull(); | ||
| 131 | expect(identity?.locator.remoteName).toBe("upstream"); | ||
| 132 | expect(identity?.canonicalKey).toBe("github.com/t3tools/t3code"); | ||
| 133 | expect(identity?.displayName).toBe("t3tools/t3code"); | ||
| 134 | }).pipe(Effect.provide(RepositoryIdentityResolverLive)), | ||
| 135 | ); | ||
| 136 | |||
| 137 | it.effect("uses the last remote path segment as the repository name for nested groups", () => | ||
| 138 | Effect.gen(function* () { | ||
| 139 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 140 | const cwd = yield* fileSystem.makeTempDirectoryScoped({ | ||
| 141 | prefix: "t3-repository-identity-nested-group-test-", | ||
| 142 | }); | ||
| 143 | |||
| 144 | yield* git(cwd, ["init"]); | ||
| 145 | yield* git(cwd, ["remote", "add", "origin", "git@gitlab.com:T3Tools/platform/t3code.git"]); | ||
| 146 | |||
| 147 | const resolver = yield* RepositoryIdentityResolver; | ||
| 148 | const identity = yield* resolver.resolve(cwd); | ||
| 149 | |||
| 150 | expect(identity).not.toBeNull(); | ||
| 151 | expect(identity?.canonicalKey).toBe("gitlab.com/t3tools/platform/t3code"); | ||
| 152 | expect(identity?.displayName).toBe("t3tools/platform/t3code"); | ||
| 153 | expect(identity?.owner).toBe("t3tools"); | ||
| 154 | expect(identity?.name).toBe("t3code"); | ||
| 155 | }).pipe(Effect.provide(RepositoryIdentityResolverLive)), | ||
| 156 | ); | ||
| 157 | |||
| 158 | it.effect( | ||
| 159 | "keeps null identities cached across repeated resolves until the negative TTL expires", | ||
| 160 | () => | ||
| 161 | Effect.gen(function* () { | ||
| 162 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 163 | const cwd = yield* fileSystem.makeTempDirectoryScoped({ | ||
| 164 | prefix: "t3-repository-identity-late-remote-test-", | ||
| 165 | }); | ||
| 166 | |||
| 167 | yield* git(cwd, ["init"]); | ||
| 168 | |||
| 169 | const resolver = yield* RepositoryIdentityResolver; | ||
| 170 | const initialIdentity = yield* resolver.resolve(cwd); | ||
| 171 | expect(initialIdentity).toBeNull(); | ||
| 172 | |||
| 173 | yield* git(cwd, ["remote", "add", "origin", "git@github.com:T3Tools/t3code.git"]); | ||
| 174 | |||
| 175 | for (const _attempt of [1, 2, 3]) { | ||
| 176 | const cachedIdentity = yield* resolver.resolve(cwd); | ||
| 177 | expect(cachedIdentity).toBeNull(); | ||
| 178 | } | ||
| 179 | |||
| 180 | yield* TestClock.adjust(Duration.millis(120)); | ||
| 181 | |||
| 182 | const refreshedIdentity = yield* resolver.resolve(cwd); | ||
| 183 | expect(refreshedIdentity).not.toBeNull(); | ||
| 184 | expect(refreshedIdentity?.canonicalKey).toBe("github.com/t3tools/t3code"); | ||
| 185 | expect(refreshedIdentity?.name).toBe("t3code"); | ||
| 186 | }).pipe( | ||
| 187 | Effect.provide( | ||
| 188 | Layer.merge( | ||
| 189 | TestClock.layer(), | ||
| 190 | makeRepositoryIdentityResolverTestLayer({ | ||
| 191 | negativeCacheTtl: Duration.millis(50), | ||
| 192 | positiveCacheTtl: Duration.seconds(1), | ||
| 193 | }), | ||
| 194 | ), | ||
| 195 | ), | ||
| 196 | ), | ||
| 197 | ); | ||
| 198 | |||
| 199 | it.effect("refreshes cached identities after the positive TTL when a remote changes", () => | ||
| 200 | Effect.gen(function* () { | ||
| 201 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 202 | const cwd = yield* fileSystem.makeTempDirectoryScoped({ | ||
| 203 | prefix: "t3-repository-identity-remote-change-test-", | ||
| 204 | }); | ||
| 205 | |||
| 206 | yield* git(cwd, ["init"]); | ||
| 207 | yield* git(cwd, ["remote", "add", "origin", "git@github.com:T3Tools/t3code.git"]); | ||
| 208 | |||
| 209 | const resolver = yield* RepositoryIdentityResolver; | ||
| 210 | const initialIdentity = yield* resolver.resolve(cwd); | ||
| 211 | expect(initialIdentity).not.toBeNull(); | ||
| 212 | expect(initialIdentity?.canonicalKey).toBe("github.com/t3tools/t3code"); | ||
| 213 | |||
| 214 | yield* git(cwd, ["remote", "set-url", "origin", "git@github.com:T3Tools/t3code-next.git"]); | ||
| 215 | |||
| 216 | const cachedIdentity = yield* resolver.resolve(cwd); | ||
| 217 | expect(cachedIdentity).not.toBeNull(); | ||
| 218 | expect(cachedIdentity?.canonicalKey).toBe("github.com/t3tools/t3code"); | ||
| 219 | |||
| 220 | yield* TestClock.adjust(Duration.millis(180)); | ||
| 221 | |||
| 222 | const refreshedIdentity = yield* resolver.resolve(cwd); | ||
| 223 | expect(refreshedIdentity).not.toBeNull(); | ||
| 224 | expect(refreshedIdentity?.canonicalKey).toBe("github.com/t3tools/t3code-next"); | ||
| 225 | expect(refreshedIdentity?.displayName).toBe("t3tools/t3code-next"); | ||
| 226 | expect(refreshedIdentity?.name).toBe("t3code-next"); | ||
| 227 | }).pipe( | ||
| 228 | Effect.provide( | ||
| 229 | Layer.merge( | ||
| 230 | TestClock.layer(), | ||
| 231 | makeRepositoryIdentityResolverTestLayer({ | ||
| 232 | negativeCacheTtl: Duration.millis(50), | ||
| 233 | positiveCacheTtl: Duration.millis(100), | ||
| 234 | }), | ||
| 235 | ), | ||
| 236 | ), | ||
| 237 | ), | ||
| 238 | ); | ||
| 239 | }); | ||
apps/server/src/project/Layers/RepositoryIdentityResolver.ts deleted-173| ... | @@ -1,173 +0,0 @@ | ||
| 1 | import type { RepositoryIdentity } from "@t3tools/contracts"; | ||
| 2 | import * as Cache from "effect/Cache"; | ||
| 3 | import * as Duration from "effect/Duration"; | ||
| 4 | import * as Effect from "effect/Effect"; | ||
| 5 | import * as Exit from "effect/Exit"; | ||
| 6 | import * as Layer from "effect/Layer"; | ||
| 7 | import { | ||
| 8 | detectSourceControlProviderFromGitRemoteUrl, | ||
| 9 | normalizeGitRemoteUrl, | ||
| 10 | } from "@t3tools/shared/git"; | ||
| 11 | |||
| 12 | import * as ProcessRunner from "../../processRunner.ts"; | ||
| 13 | import { | ||
| 14 | RepositoryIdentityResolver, | ||
| 15 | type RepositoryIdentityResolverShape, | ||
| 16 | } from "../Services/RepositoryIdentityResolver.ts"; | ||
| 17 | |||
| 18 | function parseRemoteFetchUrls(stdout: string): Map<string, string> { | ||
| 19 | const remotes = new Map<string, string>(); | ||
| 20 | for (const line of stdout.split("\n")) { | ||
| 21 | const trimmed = line.trim(); | ||
| 22 | if (trimmed.length === 0) continue; | ||
| 23 | const match = /^(\S+)\s+(\S+)\s+\((fetch|push)\)$/.exec(trimmed); | ||
| 24 | if (!match) continue; | ||
| 25 | const [, remoteName = "", remoteUrl = "", direction = ""] = match; | ||
| 26 | if (direction !== "fetch" || remoteName.length === 0 || remoteUrl.length === 0) { | ||
| 27 | continue; | ||
| 28 | } | ||
| 29 | remotes.set(remoteName, remoteUrl); | ||
| 30 | } | ||
| 31 | return remotes; | ||
| 32 | } | ||
| 33 | |||
| 34 | function pickPrimaryRemote( | ||
| 35 | remotes: ReadonlyMap<string, string>, | ||
| 36 | ): { readonly remoteName: string; readonly remoteUrl: string } | null { | ||
| 37 | for (const preferredRemoteName of ["upstream", "origin"] as const) { | ||
| 38 | const remoteUrl = remotes.get(preferredRemoteName); | ||
| 39 | if (remoteUrl) { | ||
| 40 | return { remoteName: preferredRemoteName, remoteUrl }; | ||
| 41 | } | ||
| 42 | } | ||
| 43 | |||
| 44 | const [remoteName, remoteUrl] = | ||
| 45 | [...remotes.entries()].toSorted(([left], [right]) => left.localeCompare(right))[0] ?? []; | ||
| 46 | return remoteName && remoteUrl ? { remoteName, remoteUrl } : null; | ||
| 47 | } | ||
| 48 | |||
| 49 | function buildRepositoryIdentity(input: { | ||
| 50 | readonly remoteName: string; | ||
| 51 | readonly remoteUrl: string; | ||
| 52 | readonly rootPath: string; | ||
| 53 | }): RepositoryIdentity { | ||
| 54 | const canonicalKey = normalizeGitRemoteUrl(input.remoteUrl); | ||
| 55 | const sourceControlProvider = detectSourceControlProviderFromGitRemoteUrl(input.remoteUrl); | ||
| 56 | const repositoryPath = canonicalKey.split("/").slice(1).join("/"); | ||
| 57 | const repositoryPathSegments = repositoryPath.split("/").filter((segment) => segment.length > 0); | ||
| 58 | const [owner] = repositoryPathSegments; | ||
| 59 | const repositoryName = repositoryPathSegments.at(-1); | ||
| 60 | |||
| 61 | return { | ||
| 62 | canonicalKey, | ||
| 63 | locator: { | ||
| 64 | source: "git-remote", | ||
| 65 | remoteName: input.remoteName, | ||
| 66 | remoteUrl: input.remoteUrl, | ||
| 67 | }, | ||
| 68 | rootPath: input.rootPath, | ||
| 69 | ...(repositoryPath ? { displayName: repositoryPath } : {}), | ||
| 70 | ...(sourceControlProvider ? { provider: sourceControlProvider.kind } : {}), | ||
| 71 | ...(owner ? { owner } : {}), | ||
| 72 | ...(repositoryName ? { name: repositoryName } : {}), | ||
| 73 | }; | ||
| 74 | } | ||
| 75 | |||
| 76 | const DEFAULT_REPOSITORY_IDENTITY_CACHE_CAPACITY = 512; | ||
| 77 | const DEFAULT_POSITIVE_CACHE_TTL = Duration.minutes(1); | ||
| 78 | const DEFAULT_NEGATIVE_CACHE_TTL = Duration.minutes(1); | ||
| 79 | |||
| 80 | interface RepositoryIdentityResolverOptions { | ||
| 81 | readonly cacheCapacity?: number; | ||
| 82 | readonly positiveCacheTtl?: Duration.Input; | ||
| 83 | readonly negativeCacheTtl?: Duration.Input; | ||
| 84 | } | ||
| 85 | |||
| 86 | const resolveRepositoryIdentityCacheKey = Effect.fn("resolveRepositoryIdentityCacheKey")(function* ( | ||
| 87 | cwd: string, | ||
| 88 | ) { | ||
| 89 | const processRunner = yield* ProcessRunner.ProcessRunner; | ||
| 90 | let cacheKey = cwd; | ||
| 91 | |||
| 92 | // git is a real executable on every platform — no cmd.exe shell mode, which | ||
| 93 | // would split paths containing spaces during cmd's re-tokenization. | ||
| 94 | const topLevelResult = yield* processRunner | ||
| 95 | .run({ | ||
| 96 | command: "git", | ||
| 97 | args: ["-C", cwd, "rev-parse", "--show-toplevel"], | ||
| 98 | timeoutBehavior: "timedOutResult", | ||
| 99 | }) | ||
| 100 | .pipe(Effect.option); | ||
| 101 | if (topLevelResult._tag === "None" || topLevelResult.value.code !== 0) { | ||
| 102 | return cacheKey; | ||
| 103 | } | ||
| 104 | |||
| 105 | const candidate = topLevelResult.value.stdout.trim(); | ||
| 106 | if (candidate.length > 0) { | ||
| 107 | cacheKey = candidate; | ||
| 108 | } | ||
| 109 | |||
| 110 | return cacheKey; | ||
| 111 | }); | ||
| 112 | |||
| 113 | const resolveRepositoryIdentityFromCacheKey = Effect.fn("resolveRepositoryIdentityFromCacheKey")( | ||
| 114 | function* ( | ||
| 115 | cacheKey: string, | ||
| 116 | ): Effect.fn.Return<RepositoryIdentity | null, never, ProcessRunner.ProcessRunner> { | ||
| 117 | const processRunner = yield* ProcessRunner.ProcessRunner; | ||
| 118 | const remoteResult = yield* processRunner | ||
| 119 | .run({ | ||
| 120 | command: "git", | ||
| 121 | args: ["-C", cacheKey, "remote", "-v"], | ||
| 122 | timeoutBehavior: "timedOutResult", | ||
| 123 | }) | ||
| 124 | .pipe(Effect.option); | ||
| 125 | if (remoteResult._tag === "None" || remoteResult.value.code !== 0) { | ||
| 126 | return null; | ||
| 127 | } | ||
| 128 | |||
| 129 | const remote = pickPrimaryRemote(parseRemoteFetchUrls(remoteResult.value.stdout)); | ||
| 130 | return remote ? buildRepositoryIdentity({ ...remote, rootPath: cacheKey }) : null; | ||
| 131 | }, | ||
| 132 | ); | ||
| 133 | |||
| 134 | export const makeRepositoryIdentityResolver = Effect.fn("makeRepositoryIdentityResolver")( | ||
| 135 | function* (options: RepositoryIdentityResolverOptions = {}) { | ||
| 136 | const processRunner = yield* ProcessRunner.ProcessRunner; | ||
| 137 | |||
| 138 | const repositoryIdentityCache = yield* Cache.makeWith<string, RepositoryIdentity | null>( | ||
| 139 | (cacheKey) => | ||
| 140 | resolveRepositoryIdentityFromCacheKey(cacheKey).pipe( | ||
| 141 | Effect.provideService(ProcessRunner.ProcessRunner, processRunner), | ||
| 142 | ), | ||
| 143 | { | ||
| 144 | capacity: options.cacheCapacity ?? DEFAULT_REPOSITORY_IDENTITY_CACHE_CAPACITY, | ||
| 145 | timeToLive: Exit.match({ | ||
| 146 | onSuccess: (value) => | ||
| 147 | value === null | ||
| 148 | ? (options.negativeCacheTtl ?? DEFAULT_NEGATIVE_CACHE_TTL) | ||
| 149 | : (options.positiveCacheTtl ?? DEFAULT_POSITIVE_CACHE_TTL), | ||
| 150 | onFailure: () => Duration.zero, | ||
| 151 | }), | ||
| 152 | }, | ||
| 153 | ); | ||
| 154 | |||
| 155 | const resolve: RepositoryIdentityResolverShape["resolve"] = Effect.fn( | ||
| 156 | "RepositoryIdentityResolver.resolve", | ||
| 157 | )(function* (cwd) { | ||
| 158 | const cacheKey = yield* resolveRepositoryIdentityCacheKey(cwd).pipe( | ||
| 159 | Effect.provideService(ProcessRunner.ProcessRunner, processRunner), | ||
| 160 | ); | ||
| 161 | return yield* Cache.get(repositoryIdentityCache, cacheKey); | ||
| 162 | }); | ||
| 163 | |||
| 164 | return { | ||
| 165 | resolve, | ||
| 166 | } satisfies RepositoryIdentityResolverShape; | ||
| 167 | }, | ||
| 168 | ); | ||
| 169 | |||
| 170 | export const RepositoryIdentityResolverLive = Layer.effect( | ||
| 171 | RepositoryIdentityResolver, | ||
| 172 | makeRepositoryIdentityResolver(), | ||
| 173 | ).pipe(Layer.provide(ProcessRunner.layer)); | ||
apps/server/src/project/ProjectFaviconResolver.test.ts created+77| ... | @@ -0,0 +1,77 @@ | ||
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| 2 | import { it, describe, expect } from "@effect/vitest"; | ||
| 3 | import * as Effect from "effect/Effect"; | ||
| 4 | import * as FileSystem from "effect/FileSystem"; | ||
| 5 | import * as Layer from "effect/Layer"; | ||
| 6 | import * as Path from "effect/Path"; | ||
| 7 | |||
| 8 | import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; | ||
| 9 | import * as ProjectFaviconResolver from "./ProjectFaviconResolver.ts"; | ||
| 10 | |||
| 11 | const TestLayer = Layer.empty.pipe( | ||
| 12 | Layer.provideMerge(ProjectFaviconResolver.layer.pipe(Layer.provide(WorkspacePaths.layer))), | ||
| 13 | Layer.provideMerge(NodeServices.layer), | ||
| 14 | ); | ||
| 15 | |||
| 16 | const makeTempDir = Effect.gen(function* () { | ||
| 17 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 18 | return yield* fileSystem.makeTempDirectoryScoped({ | ||
| 19 | prefix: "t3code-project-favicon-", | ||
| 20 | }); | ||
| 21 | }); | ||
| 22 | |||
| 23 | const writeTextFile = Effect.fn("writeTextFile")(function* ( | ||
| 24 | cwd: string, | ||
| 25 | relativePath: string, | ||
| 26 | contents: string, | ||
| 27 | ) { | ||
| 28 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 29 | const path = yield* Path.Path; | ||
| 30 | const absolutePath = path.join(cwd, relativePath); | ||
| 31 | yield* fileSystem | ||
| 32 | .makeDirectory(path.dirname(absolutePath), { recursive: true }) | ||
| 33 | .pipe(Effect.orDie); | ||
| 34 | yield* fileSystem.writeFileString(absolutePath, contents).pipe(Effect.orDie); | ||
| 35 | }); | ||
| 36 | |||
| 37 | it.layer(TestLayer)("ProjectFaviconResolverLive", (it) => { | ||
| 38 | describe("resolvePath", () => { | ||
| 39 | it.effect("prefers well-known favicon files", () => | ||
| 40 | Effect.gen(function* () { | ||
| 41 | const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver; | ||
| 42 | const cwd = yield* makeTempDir; | ||
| 43 | yield* writeTextFile(cwd, "favicon.svg", "<svg>favicon</svg>"); | ||
| 44 | |||
| 45 | const resolved = yield* resolver.resolvePath(cwd); | ||
| 46 | |||
| 47 | expect(resolved).not.toBeNull(); | ||
| 48 | expect(resolved).toContain("favicon.svg"); | ||
| 49 | }), | ||
| 50 | ); | ||
| 51 | |||
| 52 | it.effect("resolves icon hrefs from project source files", () => | ||
| 53 | Effect.gen(function* () { | ||
| 54 | const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver; | ||
| 55 | const cwd = yield* makeTempDir; | ||
| 56 | yield* writeTextFile(cwd, "index.html", '<link rel="icon" href="/brand/logo.svg">'); | ||
| 57 | yield* writeTextFile(cwd, "public/brand/logo.svg", "<svg>brand</svg>"); | ||
| 58 | |||
| 59 | const resolved = yield* resolver.resolvePath(cwd); | ||
| 60 | |||
| 61 | expect(resolved).not.toBeNull(); | ||
| 62 | expect(resolved).toContain("public/brand/logo.svg"); | ||
| 63 | }), | ||
| 64 | ); | ||
| 65 | |||
| 66 | it.effect("returns null when no icon is present", () => | ||
| 67 | Effect.gen(function* () { | ||
| 68 | const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver; | ||
| 69 | const cwd = yield* makeTempDir; | ||
| 70 | |||
| 71 | const resolved = yield* resolver.resolvePath(cwd); | ||
| 72 | |||
| 73 | expect(resolved).toBeNull(); | ||
| 74 | }), | ||
| 75 | ); | ||
| 76 | }); | ||
| 77 | }); | ||
apps/server/src/project/ProjectFaviconResolver.ts created+162| ... | @@ -0,0 +1,162 @@ | ||
| 1 | /** | ||
| 2 | * ProjectFaviconResolver - Effect service contract for project icon discovery. | ||
| 3 | * | ||
| 4 | * Resolves a representative favicon or app icon file for a workspace by | ||
| 5 | * checking common file locations and project source metadata. | ||
| 6 | * | ||
| 7 | * @module ProjectFaviconResolver | ||
| 8 | */ | ||
| 9 | import * as Context from "effect/Context"; | ||
| 10 | import * as Effect from "effect/Effect"; | ||
| 11 | import * as FileSystem from "effect/FileSystem"; | ||
| 12 | import * as Layer from "effect/Layer"; | ||
| 13 | import * as Path from "effect/Path"; | ||
| 14 | |||
| 15 | import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; | ||
| 16 | |||
| 17 | // Well-known favicon paths checked in order. | ||
| 18 | const FAVICON_CANDIDATES = [ | ||
| 19 | "favicon.svg", | ||
| 20 | "favicon.ico", | ||
| 21 | "favicon.png", | ||
| 22 | "public/favicon.svg", | ||
| 23 | "public/favicon.ico", | ||
| 24 | "public/favicon.png", | ||
| 25 | "app/favicon.ico", | ||
| 26 | "app/favicon.png", | ||
| 27 | "app/icon.svg", | ||
| 28 | "app/icon.png", | ||
| 29 | "app/icon.ico", | ||
| 30 | "src/favicon.ico", | ||
| 31 | "src/favicon.svg", | ||
| 32 | "src/app/favicon.ico", | ||
| 33 | "src/app/icon.svg", | ||
| 34 | "src/app/icon.png", | ||
| 35 | "assets/icon.svg", | ||
| 36 | "assets/icon.png", | ||
| 37 | "assets/logo.svg", | ||
| 38 | "assets/logo.png", | ||
| 39 | ".idea/icon.svg", | ||
| 40 | ] as const; | ||
| 41 | |||
| 42 | // Files that may contain a <link rel="icon"> or icon metadata declaration. | ||
| 43 | const ICON_SOURCE_FILES = [ | ||
| 44 | "index.html", | ||
| 45 | "public/index.html", | ||
| 46 | "app/routes/__root.tsx", | ||
| 47 | "src/routes/__root.tsx", | ||
| 48 | "app/root.tsx", | ||
| 49 | "src/root.tsx", | ||
| 50 | "src/index.html", | ||
| 51 | ] as const; | ||
| 52 | |||
| 53 | // Matches <link ...> tags or object-like icon metadata where rel/href can appear in any order. | ||
| 54 | const LINK_ICON_HTML_RE = | ||
| 55 | /<link\b(?=[^>]*\brel=["'](?:icon|shortcut icon)["'])(?=[^>]*\bhref=["']([^"'?]+))[^>]*>/i; | ||
| 56 | const LINK_ICON_OBJ_RE = | ||
| 57 | /(?=[^}]*\brel\s*:\s*["'](?:icon|shortcut icon)["'])(?=[^}]*\bhref\s*:\s*["']([^"'?]+))[^}]*/i; | ||
| 58 | |||
| 59 | /** Service tag for project favicon resolution. */ | ||
| 60 | export class ProjectFaviconResolver extends Context.Service< | ||
| 61 | ProjectFaviconResolver, | ||
| 62 | { | ||
| 63 | /** | ||
| 64 | * Resolve a favicon or icon file path for the provided workspace root. | ||
| 65 | * | ||
| 66 | * Returns `null` when no candidate icon file can be found. | ||
| 67 | */ | ||
| 68 | readonly resolvePath: (cwd: string) => Effect.Effect<string | null>; | ||
| 69 | } | ||
| 70 | >()("t3/project/ProjectFaviconResolver") {} | ||
| 71 | |||
| 72 | function extractIconHref(source: string): string | null { | ||
| 73 | const htmlMatch = source.match(LINK_ICON_HTML_RE); | ||
| 74 | if (htmlMatch?.[1]) return htmlMatch[1]; | ||
| 75 | const objMatch = source.match(LINK_ICON_OBJ_RE); | ||
| 76 | if (objMatch?.[1]) return objMatch[1]; | ||
| 77 | return null; | ||
| 78 | } | ||
| 79 | |||
| 80 | export const make = Effect.gen(function* () { | ||
| 81 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 82 | const path = yield* Path.Path; | ||
| 83 | const workspacePaths = yield* WorkspacePaths.WorkspacePaths; | ||
| 84 | |||
| 85 | const resolveIconHref = (href: string): ReadonlyArray<string> => { | ||
| 86 | const clean = href.replace(/^\//, ""); | ||
| 87 | return [path.join("public", clean), clean]; | ||
| 88 | }; | ||
| 89 | |||
| 90 | const findExistingFile = Effect.fn("ProjectFaviconResolver.findExistingFile")(function* ( | ||
| 91 | projectCwd: string, | ||
| 92 | relativeCandidates: ReadonlyArray<string>, | ||
| 93 | ): Effect.fn.Return<string | null> { | ||
| 94 | for (const relativePath of relativeCandidates) { | ||
| 95 | const candidate = yield* workspacePaths | ||
| 96 | .resolveRelativePathWithinRoot({ | ||
| 97 | workspaceRoot: projectCwd, | ||
| 98 | relativePath, | ||
| 99 | }) | ||
| 100 | .pipe(Effect.orElseSucceed(() => null)); | ||
| 101 | if (!candidate) { | ||
| 102 | continue; | ||
| 103 | } | ||
| 104 | const stats = yield* fileSystem | ||
| 105 | .stat(candidate.absolutePath) | ||
| 106 | .pipe(Effect.orElseSucceed(() => null)); | ||
| 107 | if (stats?.type === "File") { | ||
| 108 | return candidate.absolutePath; | ||
| 109 | } | ||
| 110 | } | ||
| 111 | return null; | ||
| 112 | }); | ||
| 113 | |||
| 114 | const resolvePath: ProjectFaviconResolver["Service"]["resolvePath"] = Effect.fn( | ||
| 115 | "ProjectFaviconResolver.resolvePath", | ||
| 116 | )(function* (cwd) { | ||
| 117 | const projectCwd = yield* workspacePaths | ||
| 118 | .normalizeWorkspaceRoot(cwd) | ||
| 119 | .pipe(Effect.orElseSucceed(() => null)); | ||
| 120 | if (!projectCwd) { | ||
| 121 | return null; | ||
| 122 | } | ||
| 123 | for (const candidate of FAVICON_CANDIDATES) { | ||
| 124 | const existing = yield* findExistingFile(projectCwd, [candidate]); | ||
| 125 | if (existing) { | ||
| 126 | return existing; | ||
| 127 | } | ||
| 128 | } | ||
| 129 | |||
| 130 | for (const sourceFile of ICON_SOURCE_FILES) { | ||
| 131 | const sourcePath = yield* workspacePaths | ||
| 132 | .resolveRelativePathWithinRoot({ | ||
| 133 | workspaceRoot: projectCwd, | ||
| 134 | relativePath: sourceFile, | ||
| 135 | }) | ||
| 136 | .pipe(Effect.orElseSucceed(() => null)); | ||
| 137 | if (!sourcePath) { | ||
| 138 | continue; | ||
| 139 | } | ||
| 140 | const source = yield* fileSystem | ||
| 141 | .readFileString(sourcePath.absolutePath) | ||
| 142 | .pipe(Effect.orElseSucceed(() => null)); | ||
| 143 | if (!source) { | ||
| 144 | continue; | ||
| 145 | } | ||
| 146 | const href = extractIconHref(source); | ||
| 147 | if (!href) { | ||
| 148 | continue; | ||
| 149 | } | ||
| 150 | const existing = yield* findExistingFile(projectCwd, resolveIconHref(href)); | ||
| 151 | if (existing) { | ||
| 152 | return existing; | ||
| 153 | } | ||
| 154 | } | ||
| 155 | |||
| 156 | return null; | ||
| 157 | }); | ||
| 158 | |||
| 159 | return ProjectFaviconResolver.of({ resolvePath }); | ||
| 160 | }); | ||
| 161 | |||
| 162 | export const layer = Layer.effect(ProjectFaviconResolver, make); | ||
apps/server/src/project/ProjectSetupScriptRunner.test.ts created+148| ... | @@ -0,0 +1,148 @@ | ||
| 1 | import { describe, expect, it, vi } from "@effect/vitest"; | ||
| 2 | import { type OrchestrationProject, ProjectId } from "@t3tools/contracts"; | ||
| 3 | import * as Effect from "effect/Effect"; | ||
| 4 | import * as Layer from "effect/Layer"; | ||
| 5 | import * as Option from "effect/Option"; | ||
| 6 | |||
| 7 | import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts"; | ||
| 8 | import * as TerminalManager from "../terminal/Manager.ts"; | ||
| 9 | import * as ProjectSetupScriptRunner from "./ProjectSetupScriptRunner.ts"; | ||
| 10 | |||
| 11 | const makeProject = (scripts: OrchestrationProject["scripts"]): OrchestrationProject => ({ | ||
| 12 | id: ProjectId.make("project-1"), | ||
| 13 | title: "Project", | ||
| 14 | workspaceRoot: "/repo/project", | ||
| 15 | defaultModelSelection: null, | ||
| 16 | scripts, | ||
| 17 | createdAt: "2026-01-01T00:00:00.000Z", | ||
| 18 | updatedAt: "2026-01-01T00:00:00.000Z", | ||
| 19 | deletedAt: null, | ||
| 20 | }); | ||
| 21 | |||
| 22 | const makeProjectionSnapshotQueryLayer = (project: OrchestrationProject) => | ||
| 23 | Layer.succeed(ProjectionSnapshotQuery.ProjectionSnapshotQuery, { | ||
| 24 | getCommandReadModel: () => Effect.die("unused"), | ||
| 25 | getSnapshot: () => Effect.die("unused"), | ||
| 26 | getShellSnapshot: () => Effect.die("unused"), | ||
| 27 | getArchivedShellSnapshot: () => Effect.die("unused"), | ||
| 28 | getSnapshotSequence: () => Effect.succeed({ snapshotSequence: 1 }), | ||
| 29 | getCounts: () => Effect.die("unused"), | ||
| 30 | getActiveProjectByWorkspaceRoot: (workspaceRoot) => | ||
| 31 | Effect.succeed( | ||
| 32 | workspaceRoot === project.workspaceRoot ? Option.some(project) : Option.none(), | ||
| 33 | ), | ||
| 34 | getProjectShellById: (projectId) => | ||
| 35 | Effect.succeed(projectId === project.id ? Option.some(project) : Option.none()), | ||
| 36 | getFirstActiveThreadIdByProjectId: () => Effect.die("unused"), | ||
| 37 | getThreadCheckpointContext: () => Effect.die("unused"), | ||
| 38 | getFullThreadDiffContext: () => Effect.die("unused"), | ||
| 39 | getThreadShellById: () => Effect.die("unused"), | ||
| 40 | getThreadDetailById: () => Effect.die("unused"), | ||
| 41 | }); | ||
| 42 | |||
| 43 | const makeTerminalManagerLayer = ( | ||
| 44 | overrides: Pick<TerminalManager.TerminalManager["Service"], "open" | "write">, | ||
| 45 | ) => | ||
| 46 | Layer.succeed(TerminalManager.TerminalManager, { | ||
| 47 | ...overrides, | ||
| 48 | attachStream: () => Effect.die(new Error("unused")), | ||
| 49 | resize: () => Effect.void, | ||
| 50 | clear: () => Effect.void, | ||
| 51 | restart: () => Effect.die(new Error("unused")), | ||
| 52 | close: () => Effect.void, | ||
| 53 | subscribe: () => Effect.succeed(() => undefined), | ||
| 54 | subscribeMetadata: () => Effect.succeed(() => undefined), | ||
| 55 | }); | ||
| 56 | |||
| 57 | const testLayer = ( | ||
| 58 | project: OrchestrationProject, | ||
| 59 | terminal: Pick<TerminalManager.TerminalManager["Service"], "open" | "write">, | ||
| 60 | ) => | ||
| 61 | ProjectSetupScriptRunner.layer.pipe( | ||
| 62 | Layer.provideMerge(makeProjectionSnapshotQueryLayer(project)), | ||
| 63 | Layer.provideMerge(makeTerminalManagerLayer(terminal)), | ||
| 64 | ); | ||
| 65 | |||
| 66 | describe("ProjectSetupScriptRunner", () => { | ||
| 67 | it.effect("returns no-script when no setup script exists", () => { | ||
| 68 | const open = vi.fn(() => Effect.die("unexpected open")); | ||
| 69 | const write = vi.fn(() => Effect.die("unexpected write")); | ||
| 70 | const project = makeProject([]); | ||
| 71 | |||
| 72 | return Effect.gen(function* () { | ||
| 73 | const runner = yield* ProjectSetupScriptRunner.ProjectSetupScriptRunner; | ||
| 74 | const result = yield* runner.runForThread({ | ||
| 75 | threadId: "thread-1", | ||
| 76 | projectId: "project-1", | ||
| 77 | worktreePath: "/repo/worktrees/a", | ||
| 78 | }); | ||
| 79 | |||
| 80 | expect(result).toEqual({ status: "no-script" }); | ||
| 81 | expect(open).not.toHaveBeenCalled(); | ||
| 82 | expect(write).not.toHaveBeenCalled(); | ||
| 83 | }).pipe(Effect.provide(testLayer(project, { open, write }))); | ||
| 84 | }); | ||
| 85 | |||
| 86 | it.effect( | ||
| 87 | "opens the deterministic setup terminal with worktree env and writes the command", | ||
| 88 | () => { | ||
| 89 | const open = vi.fn(() => | ||
| 90 | Effect.succeed({ | ||
| 91 | threadId: "thread-1", | ||
| 92 | terminalId: "setup-setup", | ||
| 93 | cwd: "/repo/worktrees/a", | ||
| 94 | worktreePath: "/repo/worktrees/a", | ||
| 95 | status: "running" as const, | ||
| 96 | pid: 123, | ||
| 97 | history: "", | ||
| 98 | exitCode: null, | ||
| 99 | exitSignal: null, | ||
| 100 | label: "setup-setup", | ||
| 101 | updatedAt: "2026-01-01T00:00:00.000Z", | ||
| 102 | }), | ||
| 103 | ); | ||
| 104 | const write = vi.fn(() => Effect.void); | ||
| 105 | const project = makeProject([ | ||
| 106 | { | ||
| 107 | id: "setup", | ||
| 108 | name: "Setup", | ||
| 109 | command: "bun install", | ||
| 110 | icon: "configure", | ||
| 111 | runOnWorktreeCreate: true, | ||
| 112 | }, | ||
| 113 | ]); | ||
| 114 | |||
| 115 | return Effect.gen(function* () { | ||
| 116 | const runner = yield* ProjectSetupScriptRunner.ProjectSetupScriptRunner; | ||
| 117 | const result = yield* runner.runForThread({ | ||
| 118 | threadId: "thread-1", | ||
| 119 | projectCwd: "/repo/project", | ||
| 120 | worktreePath: "/repo/worktrees/a", | ||
| 121 | }); | ||
| 122 | |||
| 123 | expect(result).toEqual({ | ||
| 124 | status: "started", | ||
| 125 | scriptId: "setup", | ||
| 126 | scriptName: "Setup", | ||
| 127 | terminalId: "setup-setup", | ||
| 128 | cwd: "/repo/worktrees/a", | ||
| 129 | }); | ||
| 130 | expect(open).toHaveBeenCalledWith({ | ||
| 131 | threadId: "thread-1", | ||
| 132 | terminalId: "setup-setup", | ||
| 133 | cwd: "/repo/worktrees/a", | ||
| 134 | worktreePath: "/repo/worktrees/a", | ||
| 135 | env: { | ||
| 136 | T3CODE_PROJECT_ROOT: "/repo/project", | ||
| 137 | T3CODE_WORKTREE_PATH: "/repo/worktrees/a", | ||
| 138 | }, | ||
| 139 | }); | ||
| 140 | expect(write).toHaveBeenCalledWith({ | ||
| 141 | threadId: "thread-1", | ||
| 142 | terminalId: "setup-setup", | ||
| 143 | data: "bun install\r", | ||
| 144 | }); | ||
| 145 | }).pipe(Effect.provide(testLayer(project, { open, write }))); | ||
| 146 | }, | ||
| 147 | ); | ||
| 148 | }); | ||
apps/server/src/project/ProjectSetupScriptRunner.ts created+179| ... | @@ -0,0 +1,179 @@ | ||
| 1 | import { ProjectId } from "@t3tools/contracts"; | ||
| 2 | import { projectScriptRuntimeEnv, setupProjectScript } from "@t3tools/shared/projectScripts"; | ||
| 3 | import * as Context from "effect/Context"; | ||
| 4 | import * as Effect from "effect/Effect"; | ||
| 5 | import * as Layer from "effect/Layer"; | ||
| 6 | import * as Option from "effect/Option"; | ||
| 7 | import * as Schema from "effect/Schema"; | ||
| 8 | |||
| 9 | import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts"; | ||
| 10 | import * as TerminalManager from "../terminal/Manager.ts"; | ||
| 11 | |||
| 12 | export interface ProjectSetupScriptRunnerResultNoScript { | ||
| 13 | readonly status: "no-script"; | ||
| 14 | } | ||
| 15 | |||
| 16 | export interface ProjectSetupScriptRunnerResultStarted { | ||
| 17 | readonly status: "started"; | ||
| 18 | readonly scriptId: string; | ||
| 19 | readonly scriptName: string; | ||
| 20 | readonly terminalId: string; | ||
| 21 | readonly cwd: string; | ||
| 22 | } | ||
| 23 | |||
| 24 | export type ProjectSetupScriptRunnerResult = | ||
| 25 | | ProjectSetupScriptRunnerResultNoScript | ||
| 26 | | ProjectSetupScriptRunnerResultStarted; | ||
| 27 | |||
| 28 | export interface ProjectSetupScriptRunnerInput { | ||
| 29 | readonly threadId: string; | ||
| 30 | readonly projectId?: string; | ||
| 31 | readonly projectCwd?: string; | ||
| 32 | readonly worktreePath: string; | ||
| 33 | readonly preferredTerminalId?: string; | ||
| 34 | } | ||
| 35 | |||
| 36 | export class ProjectSetupScriptOperationError extends Schema.TaggedErrorClass<ProjectSetupScriptOperationError>()( | ||
| 37 | "ProjectSetupScriptOperationError", | ||
| 38 | { | ||
| 39 | threadId: Schema.String, | ||
| 40 | projectId: Schema.optional(Schema.String), | ||
| 41 | projectCwd: Schema.optional(Schema.String), | ||
| 42 | worktreePath: Schema.String, | ||
| 43 | operation: Schema.Literals(["resolveProject", "openTerminal", "writeCommand"]), | ||
| 44 | cause: Schema.Defect(), | ||
| 45 | }, | ||
| 46 | ) { | ||
| 47 | override get message(): string { | ||
| 48 | return `Project setup script operation '${this.operation}' failed for thread '${this.threadId}' in '${this.worktreePath}'.`; | ||
| 49 | } | ||
| 50 | } | ||
| 51 | |||
| 52 | export class ProjectSetupScriptProjectNotFoundError extends Schema.TaggedErrorClass<ProjectSetupScriptProjectNotFoundError>()( | ||
| 53 | "ProjectSetupScriptProjectNotFoundError", | ||
| 54 | { | ||
| 55 | threadId: Schema.String, | ||
| 56 | projectId: Schema.optional(Schema.String), | ||
| 57 | projectCwd: Schema.optional(Schema.String), | ||
| 58 | worktreePath: Schema.String, | ||
| 59 | }, | ||
| 60 | ) { | ||
| 61 | override get message(): string { | ||
| 62 | return `Project setup script project was not found for thread '${this.threadId}'.`; | ||
| 63 | } | ||
| 64 | } | ||
| 65 | |||
| 66 | export const ProjectSetupScriptRunnerError = Schema.Union([ | ||
| 67 | ProjectSetupScriptOperationError, | ||
| 68 | ProjectSetupScriptProjectNotFoundError, | ||
| 69 | ]); | ||
| 70 | export type ProjectSetupScriptRunnerError = typeof ProjectSetupScriptRunnerError.Type; | ||
| 71 | |||
| 72 | export class ProjectSetupScriptRunner extends Context.Service< | ||
| 73 | ProjectSetupScriptRunner, | ||
| 74 | { | ||
| 75 | readonly runForThread: ( | ||
| 76 | input: ProjectSetupScriptRunnerInput, | ||
| 77 | ) => Effect.Effect<ProjectSetupScriptRunnerResult, ProjectSetupScriptRunnerError>; | ||
| 78 | } | ||
| 79 | >()("t3/project/ProjectSetupScriptRunner") {} | ||
| 80 | |||
| 81 | const isProjectSetupScriptRunnerError = Schema.is(ProjectSetupScriptRunnerError); | ||
| 82 | |||
| 83 | function operationError( | ||
| 84 | input: ProjectSetupScriptRunnerInput, | ||
| 85 | operation: ProjectSetupScriptOperationError["operation"], | ||
| 86 | cause: unknown, | ||
| 87 | ): ProjectSetupScriptOperationError { | ||
| 88 | return new ProjectSetupScriptOperationError({ | ||
| 89 | threadId: input.threadId, | ||
| 90 | worktreePath: input.worktreePath, | ||
| 91 | operation, | ||
| 92 | cause, | ||
| 93 | ...(input.projectId === undefined ? {} : { projectId: input.projectId }), | ||
| 94 | ...(input.projectCwd === undefined ? {} : { projectCwd: input.projectCwd }), | ||
| 95 | }); | ||
| 96 | } | ||
| 97 | |||
| 98 | function mapRunnerError( | ||
| 99 | input: ProjectSetupScriptRunnerInput, | ||
| 100 | operation: ProjectSetupScriptOperationError["operation"], | ||
| 101 | ) { | ||
| 102 | return Effect.mapError((cause: unknown) => | ||
| 103 | isProjectSetupScriptRunnerError(cause) ? cause : operationError(input, operation, cause), | ||
| 104 | ); | ||
| 105 | } | ||
| 106 | |||
| 107 | export const make = Effect.gen(function* () { | ||
| 108 | const projectionSnapshotQuery = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; | ||
| 109 | const terminalManager = yield* TerminalManager.TerminalManager; | ||
| 110 | |||
| 111 | const runForThread: ProjectSetupScriptRunner["Service"]["runForThread"] = Effect.fn( | ||
| 112 | "ProjectSetupScriptRunner.runForThread", | ||
| 113 | )(function* (input) { | ||
| 114 | const projectById = input.projectId | ||
| 115 | ? yield* projectionSnapshotQuery | ||
| 116 | .getProjectShellById(ProjectId.make(input.projectId)) | ||
| 117 | .pipe(Effect.map(Option.getOrUndefined), mapRunnerError(input, "resolveProject")) | ||
| 118 | : null; | ||
| 119 | const project = | ||
| 120 | projectById ?? | ||
| 121 | (input.projectCwd | ||
| 122 | ? yield* projectionSnapshotQuery | ||
| 123 | .getActiveProjectByWorkspaceRoot(input.projectCwd) | ||
| 124 | .pipe(Effect.map(Option.getOrUndefined), mapRunnerError(input, "resolveProject")) | ||
| 125 | : null); | ||
| 126 | |||
| 127 | if (!project) { | ||
| 128 | return yield* new ProjectSetupScriptProjectNotFoundError({ | ||
| 129 | threadId: input.threadId, | ||
| 130 | worktreePath: input.worktreePath, | ||
| 131 | ...(input.projectId === undefined ? {} : { projectId: input.projectId }), | ||
| 132 | ...(input.projectCwd === undefined ? {} : { projectCwd: input.projectCwd }), | ||
| 133 | }); | ||
| 134 | } | ||
| 135 | |||
| 136 | const script = setupProjectScript(project.scripts); | ||
| 137 | if (!script) { | ||
| 138 | return { | ||
| 139 | status: "no-script", | ||
| 140 | } as const; | ||
| 141 | } | ||
| 142 | |||
| 143 | const terminalId = input.preferredTerminalId ?? `setup-${script.id}`; | ||
| 144 | const cwd = input.worktreePath; | ||
| 145 | const env = projectScriptRuntimeEnv({ | ||
| 146 | project: { cwd: project.workspaceRoot }, | ||
| 147 | worktreePath: input.worktreePath, | ||
| 148 | }); | ||
| 149 | |||
| 150 | yield* terminalManager | ||
| 151 | .open({ | ||
| 152 | threadId: input.threadId, | ||
| 153 | terminalId, | ||
| 154 | cwd, | ||
| 155 | worktreePath: input.worktreePath, | ||
| 156 | env, | ||
| 157 | }) | ||
| 158 | .pipe(mapRunnerError(input, "openTerminal")); | ||
| 159 | yield* terminalManager | ||
| 160 | .write({ | ||
| 161 | threadId: input.threadId, | ||
| 162 | terminalId, | ||
| 163 | data: `${script.command}\r`, | ||
| 164 | }) | ||
| 165 | .pipe(mapRunnerError(input, "writeCommand")); | ||
| 166 | |||
| 167 | return { | ||
| 168 | status: "started", | ||
| 169 | scriptId: script.id, | ||
| 170 | scriptName: script.name, | ||
| 171 | terminalId, | ||
| 172 | cwd, | ||
| 173 | } as const; | ||
| 174 | }); | ||
| 175 | |||
| 176 | return ProjectSetupScriptRunner.of({ runForThread }); | ||
| 177 | }); | ||
| 178 | |||
| 179 | export const layer = Layer.effect(ProjectSetupScriptRunner, make); | ||
apps/server/src/project/RepositoryIdentityResolver.test.ts created+235| ... | @@ -0,0 +1,235 @@ | ||
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| 2 | import { expect, it } from "@effect/vitest"; | ||
| 3 | import * as Duration from "effect/Duration"; | ||
| 4 | import * as Effect from "effect/Effect"; | ||
| 5 | import * as FileSystem from "effect/FileSystem"; | ||
| 6 | import * as Layer from "effect/Layer"; | ||
| 7 | import * as Path from "effect/Path"; | ||
| 8 | import { TestClock } from "effect/testing"; | ||
| 9 | |||
| 10 | import * as ProcessRunner from "../processRunner.ts"; | ||
| 11 | import * as RepositoryIdentityResolver from "./RepositoryIdentityResolver.ts"; | ||
| 12 | |||
| 13 | const normalizePathSeparators = (value: string) => value.replaceAll("\\", "/"); | ||
| 14 | const normalizeResolvedPath = (value: string) => normalizePathSeparators(value); | ||
| 15 | |||
| 16 | const git = (cwd: string, args: ReadonlyArray<string>) => | ||
| 17 | Effect.gen(function* () { | ||
| 18 | const processRunner = yield* ProcessRunner.ProcessRunner; | ||
| 19 | return yield* processRunner.run({ | ||
| 20 | command: "git", | ||
| 21 | args: ["-C", cwd, ...args], | ||
| 22 | }); | ||
| 23 | }).pipe(Effect.provide(ProcessRunner.layer)); | ||
| 24 | |||
| 25 | const makeRepositoryIdentityResolverTestLayer = (options: { | ||
| 26 | readonly positiveCacheTtl?: Duration.Input; | ||
| 27 | readonly negativeCacheTtl?: Duration.Input; | ||
| 28 | }) => | ||
| 29 | Layer.effect( | ||
| 30 | RepositoryIdentityResolver.RepositoryIdentityResolver, | ||
| 31 | RepositoryIdentityResolver.make({ | ||
| 32 | cacheCapacity: 16, | ||
| 33 | ...options, | ||
| 34 | }), | ||
| 35 | ).pipe(Layer.provide(ProcessRunner.layer)); | ||
| 36 | |||
| 37 | it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => { | ||
| 38 | it.effect("normalizes equivalent GitHub remotes into a stable repository identity", () => | ||
| 39 | Effect.gen(function* () { | ||
| 40 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 41 | const cwd = yield* fileSystem.makeTempDirectoryScoped({ | ||
| 42 | prefix: "t3-repository-identity-test-", | ||
| 43 | }); | ||
| 44 | |||
| 45 | yield* git(cwd, ["init"]); | ||
| 46 | yield* git(cwd, ["remote", "add", "origin", "git@github.com:T3Tools/t3code.git"]); | ||
| 47 | |||
| 48 | const resolver = yield* RepositoryIdentityResolver.RepositoryIdentityResolver; | ||
| 49 | const identity = yield* resolver.resolve(cwd); | ||
| 50 | const resolvedIdentityRoot = | ||
| 51 | identity?.rootPath === undefined ? "" : yield* fileSystem.realPath(identity.rootPath); | ||
| 52 | const resolvedCwd = yield* fileSystem.realPath(cwd); | ||
| 53 | |||
| 54 | expect(identity).not.toBeNull(); | ||
| 55 | expect(identity?.canonicalKey).toBe("github.com/t3tools/t3code"); | ||
| 56 | expect(normalizeResolvedPath(resolvedIdentityRoot)).toBe(normalizeResolvedPath(resolvedCwd)); | ||
| 57 | expect(identity?.displayName).toBe("t3tools/t3code"); | ||
| 58 | expect(identity?.provider).toBe("github"); | ||
| 59 | expect(identity?.owner).toBe("t3tools"); | ||
| 60 | expect(identity?.name).toBe("t3code"); | ||
| 61 | }).pipe(Effect.provide(RepositoryIdentityResolver.layer)), | ||
| 62 | ); | ||
| 63 | |||
| 64 | it.effect("returns the git top-level root path when resolving from a nested workspace", () => | ||
| 65 | Effect.gen(function* () { | ||
| 66 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 67 | const path = yield* Path.Path; | ||
| 68 | const repoRoot = yield* fileSystem.makeTempDirectoryScoped({ | ||
| 69 | prefix: "t3-repository-identity-nested-root-test-", | ||
| 70 | }); | ||
| 71 | const nestedWorkspace = path.join(repoRoot, "packages", "web"); | ||
| 72 | |||
| 73 | yield* fileSystem.makeDirectory(nestedWorkspace, { recursive: true }); | ||
| 74 | yield* git(repoRoot, ["init"]); | ||
| 75 | yield* git(repoRoot, ["remote", "add", "origin", "git@github.com:T3Tools/t3code.git"]); | ||
| 76 | |||
| 77 | const resolver = yield* RepositoryIdentityResolver.RepositoryIdentityResolver; | ||
| 78 | const identity = yield* resolver.resolve(nestedWorkspace); | ||
| 79 | const resolvedIdentityRoot = | ||
| 80 | identity?.rootPath === undefined ? "" : yield* fileSystem.realPath(identity.rootPath); | ||
| 81 | const resolvedRepoRoot = yield* fileSystem.realPath(repoRoot); | ||
| 82 | |||
| 83 | expect(identity).not.toBeNull(); | ||
| 84 | expect(identity?.canonicalKey).toBe("github.com/t3tools/t3code"); | ||
| 85 | expect(normalizeResolvedPath(resolvedIdentityRoot)).toBe( | ||
| 86 | normalizeResolvedPath(resolvedRepoRoot), | ||
| 87 | ); | ||
| 88 | }).pipe(Effect.provide(RepositoryIdentityResolver.layer)), | ||
| 89 | ); | ||
| 90 | |||
| 91 | it.effect("returns null for non-git folders and repos without remotes", () => | ||
| 92 | Effect.gen(function* () { | ||
| 93 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 94 | const nonGitDir = yield* fileSystem.makeTempDirectoryScoped({ | ||
| 95 | prefix: "t3-repository-identity-non-git-", | ||
| 96 | }); | ||
| 97 | const gitDir = yield* fileSystem.makeTempDirectoryScoped({ | ||
| 98 | prefix: "t3-repository-identity-no-remote-", | ||
| 99 | }); | ||
| 100 | |||
| 101 | yield* git(gitDir, ["init"]); | ||
| 102 | |||
| 103 | const resolver = yield* RepositoryIdentityResolver.RepositoryIdentityResolver; | ||
| 104 | const nonGitIdentity = yield* resolver.resolve(nonGitDir); | ||
| 105 | const noRemoteIdentity = yield* resolver.resolve(gitDir); | ||
| 106 | |||
| 107 | expect(nonGitIdentity).toBeNull(); | ||
| 108 | expect(noRemoteIdentity).toBeNull(); | ||
| 109 | }).pipe(Effect.provide(RepositoryIdentityResolver.layer)), | ||
| 110 | ); | ||
| 111 | |||
| 112 | it.effect("prefers upstream over origin when both remotes are configured", () => | ||
| 113 | Effect.gen(function* () { | ||
| 114 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 115 | const cwd = yield* fileSystem.makeTempDirectoryScoped({ | ||
| 116 | prefix: "t3-repository-identity-upstream-test-", | ||
| 117 | }); | ||
| 118 | |||
| 119 | yield* git(cwd, ["init"]); | ||
| 120 | yield* git(cwd, ["remote", "add", "origin", "git@github.com:julius/t3code.git"]); | ||
| 121 | yield* git(cwd, ["remote", "add", "upstream", "git@github.com:T3Tools/t3code.git"]); | ||
| 122 | |||
| 123 | const resolver = yield* RepositoryIdentityResolver.RepositoryIdentityResolver; | ||
| 124 | const identity = yield* resolver.resolve(cwd); | ||
| 125 | |||
| 126 | expect(identity).not.toBeNull(); | ||
| 127 | expect(identity?.locator.remoteName).toBe("upstream"); | ||
| 128 | expect(identity?.canonicalKey).toBe("github.com/t3tools/t3code"); | ||
| 129 | expect(identity?.displayName).toBe("t3tools/t3code"); | ||
| 130 | }).pipe(Effect.provide(RepositoryIdentityResolver.layer)), | ||
| 131 | ); | ||
| 132 | |||
| 133 | it.effect("uses the last remote path segment as the repository name for nested groups", () => | ||
| 134 | Effect.gen(function* () { | ||
| 135 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 136 | const cwd = yield* fileSystem.makeTempDirectoryScoped({ | ||
| 137 | prefix: "t3-repository-identity-nested-group-test-", | ||
| 138 | }); | ||
| 139 | |||
| 140 | yield* git(cwd, ["init"]); | ||
| 141 | yield* git(cwd, ["remote", "add", "origin", "git@gitlab.com:T3Tools/platform/t3code.git"]); | ||
| 142 | |||
| 143 | const resolver = yield* RepositoryIdentityResolver.RepositoryIdentityResolver; | ||
| 144 | const identity = yield* resolver.resolve(cwd); | ||
| 145 | |||
| 146 | expect(identity).not.toBeNull(); | ||
| 147 | expect(identity?.canonicalKey).toBe("gitlab.com/t3tools/platform/t3code"); | ||
| 148 | expect(identity?.displayName).toBe("t3tools/platform/t3code"); | ||
| 149 | expect(identity?.owner).toBe("t3tools"); | ||
| 150 | expect(identity?.name).toBe("t3code"); | ||
| 151 | }).pipe(Effect.provide(RepositoryIdentityResolver.layer)), | ||
| 152 | ); | ||
| 153 | |||
| 154 | it.effect( | ||
| 155 | "keeps null identities cached across repeated resolves until the negative TTL expires", | ||
| 156 | () => | ||
| 157 | Effect.gen(function* () { | ||
| 158 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 159 | const cwd = yield* fileSystem.makeTempDirectoryScoped({ | ||
| 160 | prefix: "t3-repository-identity-late-remote-test-", | ||
| 161 | }); | ||
| 162 | |||
| 163 | yield* git(cwd, ["init"]); | ||
| 164 | |||
| 165 | const resolver = yield* RepositoryIdentityResolver.RepositoryIdentityResolver; | ||
| 166 | const initialIdentity = yield* resolver.resolve(cwd); | ||
| 167 | expect(initialIdentity).toBeNull(); | ||
| 168 | |||
| 169 | yield* git(cwd, ["remote", "add", "origin", "git@github.com:T3Tools/t3code.git"]); | ||
| 170 | |||
| 171 | for (const _attempt of [1, 2, 3]) { | ||
| 172 | const cachedIdentity = yield* resolver.resolve(cwd); | ||
| 173 | expect(cachedIdentity).toBeNull(); | ||
| 174 | } | ||
| 175 | |||
| 176 | yield* TestClock.adjust(Duration.millis(120)); | ||
| 177 | |||
| 178 | const refreshedIdentity = yield* resolver.resolve(cwd); | ||
| 179 | expect(refreshedIdentity).not.toBeNull(); | ||
| 180 | expect(refreshedIdentity?.canonicalKey).toBe("github.com/t3tools/t3code"); | ||
| 181 | expect(refreshedIdentity?.name).toBe("t3code"); | ||
| 182 | }).pipe( | ||
| 183 | Effect.provide( | ||
| 184 | Layer.merge( | ||
| 185 | TestClock.layer(), | ||
| 186 | makeRepositoryIdentityResolverTestLayer({ | ||
| 187 | negativeCacheTtl: Duration.millis(50), | ||
| 188 | positiveCacheTtl: Duration.seconds(1), | ||
| 189 | }), | ||
| 190 | ), | ||
| 191 | ), | ||
| 192 | ), | ||
| 193 | ); | ||
| 194 | |||
| 195 | it.effect("refreshes cached identities after the positive TTL when a remote changes", () => | ||
| 196 | Effect.gen(function* () { | ||
| 197 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 198 | const cwd = yield* fileSystem.makeTempDirectoryScoped({ | ||
| 199 | prefix: "t3-repository-identity-remote-change-test-", | ||
| 200 | }); | ||
| 201 | |||
| 202 | yield* git(cwd, ["init"]); | ||
| 203 | yield* git(cwd, ["remote", "add", "origin", "git@github.com:T3Tools/t3code.git"]); | ||
| 204 | |||
| 205 | const resolver = yield* RepositoryIdentityResolver.RepositoryIdentityResolver; | ||
| 206 | const initialIdentity = yield* resolver.resolve(cwd); | ||
| 207 | expect(initialIdentity).not.toBeNull(); | ||
| 208 | expect(initialIdentity?.canonicalKey).toBe("github.com/t3tools/t3code"); | ||
| 209 | |||
| 210 | yield* git(cwd, ["remote", "set-url", "origin", "git@github.com:T3Tools/t3code-next.git"]); | ||
| 211 | |||
| 212 | const cachedIdentity = yield* resolver.resolve(cwd); | ||
| 213 | expect(cachedIdentity).not.toBeNull(); | ||
| 214 | expect(cachedIdentity?.canonicalKey).toBe("github.com/t3tools/t3code"); | ||
| 215 | |||
| 216 | yield* TestClock.adjust(Duration.millis(180)); | ||
| 217 | |||
| 218 | const refreshedIdentity = yield* resolver.resolve(cwd); | ||
| 219 | expect(refreshedIdentity).not.toBeNull(); | ||
| 220 | expect(refreshedIdentity?.canonicalKey).toBe("github.com/t3tools/t3code-next"); | ||
| 221 | expect(refreshedIdentity?.displayName).toBe("t3tools/t3code-next"); | ||
| 222 | expect(refreshedIdentity?.name).toBe("t3code-next"); | ||
| 223 | }).pipe( | ||
| 224 | Effect.provide( | ||
| 225 | Layer.merge( | ||
| 226 | TestClock.layer(), | ||
| 227 | makeRepositoryIdentityResolverTestLayer({ | ||
| 228 | negativeCacheTtl: Duration.millis(50), | ||
| 229 | positiveCacheTtl: Duration.millis(100), | ||
| 230 | }), | ||
| 231 | ), | ||
| 232 | ), | ||
| 233 | ), | ||
| 234 | ); | ||
| 235 | }); | ||
apps/server/src/project/RepositoryIdentityResolver.ts created+174| ... | @@ -0,0 +1,174 @@ | ||
| 1 | import type { RepositoryIdentity } from "@t3tools/contracts"; | ||
| 2 | import { | ||
| 3 | detectSourceControlProviderFromGitRemoteUrl, | ||
| 4 | normalizeGitRemoteUrl, | ||
| 5 | } from "@t3tools/shared/git"; | ||
| 6 | import * as Cache from "effect/Cache"; | ||
| 7 | import * as Context from "effect/Context"; | ||
| 8 | import * as Duration from "effect/Duration"; | ||
| 9 | import * as Effect from "effect/Effect"; | ||
| 10 | import * as Exit from "effect/Exit"; | ||
| 11 | import * as Layer from "effect/Layer"; | ||
| 12 | |||
| 13 | import * as ProcessRunner from "../processRunner.ts"; | ||
| 14 | |||
| 15 | const DEFAULT_REPOSITORY_IDENTITY_CACHE_CAPACITY = 512; | ||
| 16 | const DEFAULT_POSITIVE_CACHE_TTL = Duration.minutes(1); | ||
| 17 | const DEFAULT_NEGATIVE_CACHE_TTL = Duration.minutes(1); | ||
| 18 | |||
| 19 | export interface RepositoryIdentityResolverOptions { | ||
| 20 | readonly cacheCapacity?: number; | ||
| 21 | readonly positiveCacheTtl?: Duration.Input; | ||
| 22 | readonly negativeCacheTtl?: Duration.Input; | ||
| 23 | } | ||
| 24 | |||
| 25 | export class RepositoryIdentityResolver extends Context.Service< | ||
| 26 | RepositoryIdentityResolver, | ||
| 27 | { | ||
| 28 | readonly resolve: (cwd: string) => Effect.Effect<RepositoryIdentity | null>; | ||
| 29 | } | ||
| 30 | >()("t3/project/RepositoryIdentityResolver") {} | ||
| 31 | |||
| 32 | function parseRemoteFetchUrls(stdout: string): Map<string, string> { | ||
| 33 | const remotes = new Map<string, string>(); | ||
| 34 | for (const line of stdout.split("\n")) { | ||
| 35 | const trimmed = line.trim(); | ||
| 36 | if (trimmed.length === 0) continue; | ||
| 37 | const match = /^(\S+)\s+(\S+)\s+\((fetch|push)\)$/.exec(trimmed); | ||
| 38 | if (!match) continue; | ||
| 39 | const [, remoteName = "", remoteUrl = "", direction = ""] = match; | ||
| 40 | if (direction !== "fetch" || remoteName.length === 0 || remoteUrl.length === 0) { | ||
| 41 | continue; | ||
| 42 | } | ||
| 43 | remotes.set(remoteName, remoteUrl); | ||
| 44 | } | ||
| 45 | return remotes; | ||
| 46 | } | ||
| 47 | |||
| 48 | function pickPrimaryRemote( | ||
| 49 | remotes: ReadonlyMap<string, string>, | ||
| 50 | ): { readonly remoteName: string; readonly remoteUrl: string } | null { | ||
| 51 | for (const preferredRemoteName of ["upstream", "origin"] as const) { | ||
| 52 | const remoteUrl = remotes.get(preferredRemoteName); | ||
| 53 | if (remoteUrl) { | ||
| 54 | return { remoteName: preferredRemoteName, remoteUrl }; | ||
| 55 | } | ||
| 56 | } | ||
| 57 | |||
| 58 | const [remoteName, remoteUrl] = | ||
| 59 | [...remotes.entries()].toSorted(([left], [right]) => left.localeCompare(right))[0] ?? []; | ||
| 60 | return remoteName && remoteUrl ? { remoteName, remoteUrl } : null; | ||
| 61 | } | ||
| 62 | |||
| 63 | function buildRepositoryIdentity(input: { | ||
| 64 | readonly remoteName: string; | ||
| 65 | readonly remoteUrl: string; | ||
| 66 | readonly rootPath: string; | ||
| 67 | }): RepositoryIdentity { | ||
| 68 | const canonicalKey = normalizeGitRemoteUrl(input.remoteUrl); | ||
| 69 | const sourceControlProvider = detectSourceControlProviderFromGitRemoteUrl(input.remoteUrl); | ||
| 70 | const repositoryPath = canonicalKey.split("/").slice(1).join("/"); | ||
| 71 | const repositoryPathSegments = repositoryPath.split("/").filter((segment) => segment.length > 0); | ||
| 72 | const [owner] = repositoryPathSegments; | ||
| 73 | const repositoryName = repositoryPathSegments.at(-1); | ||
| 74 | |||
| 75 | return { | ||
| 76 | canonicalKey, | ||
| 77 | locator: { | ||
| 78 | source: "git-remote", | ||
| 79 | remoteName: input.remoteName, | ||
| 80 | remoteUrl: input.remoteUrl, | ||
| 81 | }, | ||
| 82 | rootPath: input.rootPath, | ||
| 83 | ...(repositoryPath ? { displayName: repositoryPath } : {}), | ||
| 84 | ...(sourceControlProvider ? { provider: sourceControlProvider.kind } : {}), | ||
| 85 | ...(owner ? { owner } : {}), | ||
| 86 | ...(repositoryName ? { name: repositoryName } : {}), | ||
| 87 | }; | ||
| 88 | } | ||
| 89 | |||
| 90 | const resolveRepositoryIdentityCacheKey = Effect.fn("RepositoryIdentityResolver.resolveCacheKey")( | ||
| 91 | function* (cwd: string) { | ||
| 92 | const processRunner = yield* ProcessRunner.ProcessRunner; | ||
| 93 | let cacheKey = cwd; | ||
| 94 | |||
| 95 | // git is a real executable on every platform — no cmd.exe shell mode, which | ||
| 96 | // would split paths containing spaces during cmd's re-tokenization. | ||
| 97 | const topLevelResult = yield* processRunner | ||
| 98 | .run({ | ||
| 99 | command: "git", | ||
| 100 | args: ["-C", cwd, "rev-parse", "--show-toplevel"], | ||
| 101 | timeoutBehavior: "timedOutResult", | ||
| 102 | }) | ||
| 103 | .pipe(Effect.option); | ||
| 104 | if (topLevelResult._tag === "None" || topLevelResult.value.code !== 0) { | ||
| 105 | return cacheKey; | ||
| 106 | } | ||
| 107 | |||
| 108 | const candidate = topLevelResult.value.stdout.trim(); | ||
| 109 | if (candidate.length > 0) { | ||
| 110 | cacheKey = candidate; | ||
| 111 | } | ||
| 112 | |||
| 113 | return cacheKey; | ||
| 114 | }, | ||
| 115 | ); | ||
| 116 | |||
| 117 | const resolveRepositoryIdentityFromCacheKey = Effect.fn( | ||
| 118 | "RepositoryIdentityResolver.resolveFromCacheKey", | ||
| 119 | )(function* ( | ||
| 120 | cacheKey: string, | ||
| 121 | ): Effect.fn.Return<RepositoryIdentity | null, never, ProcessRunner.ProcessRunner> { | ||
| 122 | const processRunner = yield* ProcessRunner.ProcessRunner; | ||
| 123 | const remoteResult = yield* processRunner | ||
| 124 | .run({ | ||
| 125 | command: "git", | ||
| 126 | args: ["-C", cacheKey, "remote", "-v"], | ||
| 127 | timeoutBehavior: "timedOutResult", | ||
| 128 | }) | ||
| 129 | .pipe(Effect.option); | ||
| 130 | if (remoteResult._tag === "None" || remoteResult.value.code !== 0) { | ||
| 131 | return null; | ||
| 132 | } | ||
| 133 | |||
| 134 | const remote = pickPrimaryRemote(parseRemoteFetchUrls(remoteResult.value.stdout)); | ||
| 135 | return remote ? buildRepositoryIdentity({ ...remote, rootPath: cacheKey }) : null; | ||
| 136 | }); | ||
| 137 | |||
| 138 | export const make = Effect.fn("RepositoryIdentityResolver.make")(function* ( | ||
| 139 | options: RepositoryIdentityResolverOptions = {}, | ||
| 140 | ) { | ||
| 141 | const processRunner = yield* ProcessRunner.ProcessRunner; | ||
| 142 | |||
| 143 | const repositoryIdentityCache = yield* Cache.makeWith<string, RepositoryIdentity | null>( | ||
| 144 | (cacheKey) => | ||
| 145 | resolveRepositoryIdentityFromCacheKey(cacheKey).pipe( | ||
| 146 | Effect.provideService(ProcessRunner.ProcessRunner, processRunner), | ||
| 147 | ), | ||
| 148 | { | ||
| 149 | capacity: options.cacheCapacity ?? DEFAULT_REPOSITORY_IDENTITY_CACHE_CAPACITY, | ||
| 150 | timeToLive: Exit.match({ | ||
| 151 | onSuccess: (value) => | ||
| 152 | value === null | ||
| 153 | ? (options.negativeCacheTtl ?? DEFAULT_NEGATIVE_CACHE_TTL) | ||
| 154 | : (options.positiveCacheTtl ?? DEFAULT_POSITIVE_CACHE_TTL), | ||
| 155 | onFailure: () => Duration.zero, | ||
| 156 | }), | ||
| 157 | }, | ||
| 158 | ); | ||
| 159 | |||
| 160 | const resolve: RepositoryIdentityResolver["Service"]["resolve"] = Effect.fn( | ||
| 161 | "RepositoryIdentityResolver.resolve", | ||
| 162 | )(function* (cwd) { | ||
| 163 | const cacheKey = yield* resolveRepositoryIdentityCacheKey(cwd).pipe( | ||
| 164 | Effect.provideService(ProcessRunner.ProcessRunner, processRunner), | ||
| 165 | ); | ||
| 166 | return yield* Cache.get(repositoryIdentityCache, cacheKey); | ||
| 167 | }); | ||
| 168 | |||
| 169 | return RepositoryIdentityResolver.of({ resolve }); | ||
| 170 | }); | ||
| 171 | |||
| 172 | export const layer = Layer.effect(RepositoryIdentityResolver, make()).pipe( | ||
| 173 | Layer.provide(ProcessRunner.layer), | ||
| 174 | ); | ||
apps/server/src/project/Services/ProjectFaviconResolver.ts deleted-30| ... | @@ -1,30 +0,0 @@ | ||
| 1 | /** | ||
| 2 | * ProjectFaviconResolver - Effect service contract for project icon discovery. | ||
| 3 | * | ||
| 4 | * Resolves a representative favicon or app icon file for a workspace by | ||
| 5 | * checking common file locations and project source metadata. | ||
| 6 | * | ||
| 7 | * @module ProjectFaviconResolver | ||
| 8 | */ | ||
| 9 | import * as Context from "effect/Context"; | ||
| 10 | import type * as Effect from "effect/Effect"; | ||
| 11 | |||
| 12 | /** | ||
| 13 | * ProjectFaviconResolverShape - Service API for project favicon lookup. | ||
| 14 | */ | ||
| 15 | export interface ProjectFaviconResolverShape { | ||
| 16 | /** | ||
| 17 | * Resolve a favicon or icon file path for the provided workspace root. | ||
| 18 | * | ||
| 19 | * Returns `null` when no candidate icon file can be found. | ||
| 20 | */ | ||
| 21 | readonly resolvePath: (cwd: string) => Effect.Effect<string | null>; | ||
| 22 | } | ||
| 23 | |||
| 24 | /** | ||
| 25 | * ProjectFaviconResolver - Service tag for project favicon resolution. | ||
| 26 | */ | ||
| 27 | export class ProjectFaviconResolver extends Context.Service< | ||
| 28 | ProjectFaviconResolver, | ||
| 29 | ProjectFaviconResolverShape | ||
| 30 | >()("t3/project/Services/ProjectFaviconResolver") {} | ||
apps/server/src/project/Services/ProjectSetupScriptRunner.ts deleted-44| ... | @@ -1,44 +0,0 @@ | ||
| 1 | import * as Context from "effect/Context"; | ||
| 2 | import * as Data from "effect/Data"; | ||
| 3 | import type * as Effect from "effect/Effect"; | ||
| 4 | |||
| 5 | export interface ProjectSetupScriptRunnerResultNoScript { | ||
| 6 | readonly status: "no-script"; | ||
| 7 | } | ||
| 8 | |||
| 9 | export interface ProjectSetupScriptRunnerResultStarted { | ||
| 10 | readonly status: "started"; | ||
| 11 | readonly scriptId: string; | ||
| 12 | readonly scriptName: string; | ||
| 13 | readonly terminalId: string; | ||
| 14 | readonly cwd: string; | ||
| 15 | } | ||
| 16 | |||
| 17 | export type ProjectSetupScriptRunnerResult = | ||
| 18 | | ProjectSetupScriptRunnerResultNoScript | ||
| 19 | | ProjectSetupScriptRunnerResultStarted; | ||
| 20 | |||
| 21 | export interface ProjectSetupScriptRunnerInput { | ||
| 22 | readonly threadId: string; | ||
| 23 | readonly projectId?: string; | ||
| 24 | readonly projectCwd?: string; | ||
| 25 | readonly worktreePath: string; | ||
| 26 | readonly preferredTerminalId?: string; | ||
| 27 | } | ||
| 28 | |||
| 29 | export class ProjectSetupScriptRunnerError extends Data.TaggedError( | ||
| 30 | "ProjectSetupScriptRunnerError", | ||
| 31 | )<{ | ||
| 32 | readonly message: string; | ||
| 33 | }> {} | ||
| 34 | |||
| 35 | export interface ProjectSetupScriptRunnerShape { | ||
| 36 | readonly runForThread: ( | ||
| 37 | input: ProjectSetupScriptRunnerInput, | ||
| 38 | ) => Effect.Effect<ProjectSetupScriptRunnerResult, ProjectSetupScriptRunnerError>; | ||
| 39 | } | ||
| 40 | |||
| 41 | export class ProjectSetupScriptRunner extends Context.Service< | ||
| 42 | ProjectSetupScriptRunner, | ||
| 43 | ProjectSetupScriptRunnerShape | ||
| 44 | >()("t3/project/Services/ProjectSetupScriptRunner") {} | ||
apps/server/src/project/Services/RepositoryIdentityResolver.ts deleted-12| ... | @@ -1,12 +0,0 @@ | ||
| 1 | import type { RepositoryIdentity } from "@t3tools/contracts"; | ||
| 2 | import * as Context from "effect/Context"; | ||
| 3 | import type * as Effect from "effect/Effect"; | ||
| 4 | |||
| 5 | export interface RepositoryIdentityResolverShape { | ||
| 6 | readonly resolve: (cwd: string) => Effect.Effect<RepositoryIdentity | null>; | ||
| 7 | } | ||
| 8 | |||
| 9 | export class RepositoryIdentityResolver extends Context.Service< | ||
| 10 | RepositoryIdentityResolver, | ||
| 11 | RepositoryIdentityResolverShape | ||
| 12 | >()("t3/project/Services/RepositoryIdentityResolver") {} | ||
apps/server/src/relay/AgentAwarenessRelay.test.ts+5-5| ... | @@ -1,4 +1,4 @@ | ... | @@ -1,4 +1,4 @@ |
| 1 | import * as NodeCrypto from "node:crypto"; | 1 | import { generateKeyPairSync } from "node:crypto"; |
| 2 | import * as NodeServices from "@effect/platform-node/NodeServices"; | 2 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 3 | 3 | ||
| 4 | import type { | 4 | import type { |
| ... | @@ -29,7 +29,7 @@ import * as Stream from "effect/Stream"; | ... | @@ -29,7 +29,7 @@ import * as Stream from "effect/Stream"; |
| 29 | import * as Tracer from "effect/Tracer"; | 29 | import * as Tracer from "effect/Tracer"; |
| 30 | 30 | ||
| 31 | import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; | 31 | import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; |
| 32 | import { ServerEnvironment } from "../environment/Services/ServerEnvironment.ts"; | 32 | import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; |
| 33 | import { | 33 | import { |
| 34 | OrchestrationEngineService, | 34 | OrchestrationEngineService, |
| 35 | type OrchestrationEngineShape, | 35 | type OrchestrationEngineShape, |
| ... | @@ -348,7 +348,7 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { | ... | @@ -348,7 +348,7 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { |
| 348 | }); | 348 | }); |
| 349 | 349 | ||
| 350 | it("signs the activity publish JWT and rejects tampering", async () => { | 350 | it("signs the activity publish JWT and rejects tampering", async () => { |
| 351 | const keyPair = NodeCrypto.generateKeyPairSync("ed25519", { | 351 | const keyPair = generateKeyPairSync("ed25519", { |
| 352 | privateKeyEncoding: { format: "pem", type: "pkcs8" }, | 352 | privateKeyEncoding: { format: "pem", type: "pkcs8" }, |
| 353 | publicKeyEncoding: { format: "pem", type: "spki" }, | 353 | publicKeyEncoding: { format: "pem", type: "spki" }, |
| 354 | }); | 354 | }); |
| ... | @@ -494,7 +494,7 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { | ... | @@ -494,7 +494,7 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { |
| 494 | 494 | ||
| 495 | const layer = Layer.mergeAll( | 495 | const layer = Layer.mergeAll( |
| 496 | Layer.succeed(ServerSecretStore.ServerSecretStore, secrets.store), | 496 | Layer.succeed(ServerSecretStore.ServerSecretStore, secrets.store), |
| 497 | Layer.succeed(ServerEnvironment, { | 497 | Layer.succeed(ServerEnvironment.ServerEnvironment, { |
| 498 | getEnvironmentId: Effect.succeed(environmentId), | 498 | getEnvironmentId: Effect.succeed(environmentId), |
| 499 | getDescriptor: Effect.succeed(descriptor), | 499 | getDescriptor: Effect.succeed(descriptor), |
| 500 | }), | 500 | }), |
| ... | @@ -642,7 +642,7 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { | ... | @@ -642,7 +642,7 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { |
| 642 | 642 | ||
| 643 | const layer = Layer.mergeAll( | 643 | const layer = Layer.mergeAll( |
| 644 | Layer.succeed(ServerSecretStore.ServerSecretStore, secrets.store), | 644 | Layer.succeed(ServerSecretStore.ServerSecretStore, secrets.store), |
| 645 | Layer.succeed(ServerEnvironment, { | 645 | Layer.succeed(ServerEnvironment.ServerEnvironment, { |
| 646 | getEnvironmentId: Effect.succeed(environmentId), | 646 | getEnvironmentId: Effect.succeed(environmentId), |
| 647 | getDescriptor: Effect.succeed(descriptor), | 647 | getDescriptor: Effect.succeed(descriptor), |
| 648 | }), | 648 | }), |
apps/server/src/relay/AgentAwarenessRelay.ts+1-1| ... | @@ -41,7 +41,7 @@ import { | ... | @@ -41,7 +41,7 @@ import { |
| 41 | RELAY_URL_SECRET, | 41 | RELAY_URL_SECRET, |
| 42 | } from "../cloud/config.ts"; | 42 | } from "../cloud/config.ts"; |
| 43 | import { getOrCreateEnvironmentKeyPairFromSecretStore } from "../cloud/environmentKeys.ts"; | 43 | import { getOrCreateEnvironmentKeyPairFromSecretStore } from "../cloud/environmentKeys.ts"; |
| 44 | import * as ServerEnvironment from "../environment/Services/ServerEnvironment.ts"; | 44 | import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; |
| 45 | import * as OrchestrationEngine from "../orchestration/Services/OrchestrationEngine.ts"; | 45 | import * as OrchestrationEngine from "../orchestration/Services/OrchestrationEngine.ts"; |
| 46 | import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts"; | 46 | import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts"; |
| 47 | 47 |
apps/server/src/server.test.ts+42-41| ... | @@ -1,7 +1,7 @@ | ... | @@ -1,7 +1,7 @@ |
| 1 | import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; | 1 | import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; |
| 2 | import * as NodeSocket from "@effect/platform-node/NodeSocket"; | 2 | import * as NodeSocket from "@effect/platform-node/NodeSocket"; |
| 3 | import * as NodeServices from "@effect/platform-node/NodeServices"; | 3 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 4 | import * as NodeCrypto from "node:crypto"; | 4 | import { generateKeyPairSync, type KeyObject, sign } from "node:crypto"; |
| 5 | 5 | ||
| 6 | import { | 6 | import { |
| 7 | AuthAccessTokenType, | 7 | AuthAccessTokenType, |
| ... | @@ -89,13 +89,13 @@ import * as TerminalManager from "./terminal/Manager.ts"; | ... | @@ -89,13 +89,13 @@ import * as TerminalManager from "./terminal/Manager.ts"; |
| 89 | import * as PreviewManager from "./preview/Manager.ts"; | 89 | import * as PreviewManager from "./preview/Manager.ts"; |
| 90 | import * as PortScanner from "./preview/PortScanner.ts"; | 90 | import * as PortScanner from "./preview/PortScanner.ts"; |
| 91 | import * as BrowserTraceCollector from "./observability/BrowserTraceCollector.ts"; | 91 | import * as BrowserTraceCollector from "./observability/BrowserTraceCollector.ts"; |
| 92 | import { ProjectFaviconResolverLive } from "./project/Layers/ProjectFaviconResolver.ts"; | 92 | import * as ProjectFaviconResolver from "./project/ProjectFaviconResolver.ts"; |
| 93 | import * as ProjectSetupScriptRunner from "./project/Services/ProjectSetupScriptRunner.ts"; | 93 | import * as ProjectSetupScriptRunner from "./project/ProjectSetupScriptRunner.ts"; |
| 94 | import * as RepositoryIdentityResolver from "./project/Services/RepositoryIdentityResolver.ts"; | 94 | import * as RepositoryIdentityResolver from "./project/RepositoryIdentityResolver.ts"; |
| 95 | import * as ServerEnvironment from "./environment/Services/ServerEnvironment.ts"; | 95 | import * as ServerEnvironment from "./environment/ServerEnvironment.ts"; |
| 96 | import * as WorkspaceEntries from "./workspace/WorkspaceEntries.ts"; | 96 | import * as WorkspaceEntries from "./workspace/WorkspaceEntries.ts"; |
| 97 | import { WorkspaceFileSystemLive } from "./workspace/Layers/WorkspaceFileSystem.ts"; | 97 | import * as WorkspaceFileSystem from "./workspace/WorkspaceFileSystem.ts"; |
| 98 | import { WorkspacePathsLive } from "./workspace/Layers/WorkspacePaths.ts"; | 98 | import * as WorkspacePaths from "./workspace/WorkspacePaths.ts"; |
| 99 | import * as GitVcsDriver from "./vcs/GitVcsDriver.ts"; | 99 | import * as GitVcsDriver from "./vcs/GitVcsDriver.ts"; |
| 100 | import * as VcsDriver from "./vcs/VcsDriver.ts"; | 100 | import * as VcsDriver from "./vcs/VcsDriver.ts"; |
| 101 | import * as VcsStatusBroadcaster from "./vcs/VcsStatusBroadcaster.ts"; | 101 | import * as VcsStatusBroadcaster from "./vcs/VcsStatusBroadcaster.ts"; |
| ... | @@ -485,17 +485,17 @@ const buildAppUnderTest = (options?: { | ... | @@ -485,17 +485,17 @@ const buildAppUnderTest = (options?: { |
| 485 | ...options?.layers?.gitManager, | 485 | ...options?.layers?.gitManager, |
| 486 | }); | 486 | }); |
| 487 | const workspaceEntriesLayer = WorkspaceEntries.layer.pipe( | 487 | const workspaceEntriesLayer = WorkspaceEntries.layer.pipe( |
| 488 | Layer.provide(WorkspacePathsLive), | 488 | Layer.provide(WorkspacePaths.layer), |
| 489 | Layer.provideMerge(vcsDriverRegistryLayer), | 489 | Layer.provideMerge(vcsDriverRegistryLayer), |
| 490 | ); | 490 | ); |
| 491 | const workspaceAndProjectServicesLayer = Layer.mergeAll( | 491 | const workspaceAndProjectServicesLayer = Layer.mergeAll( |
| 492 | WorkspacePathsLive, | 492 | WorkspacePaths.layer, |
| 493 | workspaceEntriesLayer, | 493 | workspaceEntriesLayer, |
| 494 | WorkspaceFileSystemLive.pipe( | 494 | WorkspaceFileSystem.layer.pipe( |
| 495 | Layer.provide(WorkspacePathsLive), | 495 | Layer.provide(WorkspacePaths.layer), |
| 496 | Layer.provide(workspaceEntriesLayer), | 496 | Layer.provide(workspaceEntriesLayer), |
| 497 | ), | 497 | ), |
| 498 | ProjectFaviconResolverLive.pipe(Layer.provide(WorkspacePathsLive)), | 498 | ProjectFaviconResolver.layer.pipe(Layer.provide(WorkspacePaths.layer)), |
| 499 | ); | 499 | ); |
| 500 | const gitWorkflowLayer = GitWorkflowService.layer.pipe( | 500 | const gitWorkflowLayer = GitWorkflowService.layer.pipe( |
| 501 | Layer.provideMerge(vcsDriverRegistryLayer), | 501 | Layer.provideMerge(vcsDriverRegistryLayer), |
| ... | @@ -950,14 +950,14 @@ const makeDpopProof = (input: { | ... | @@ -950,14 +950,14 @@ const makeDpopProof = (input: { |
| 950 | readonly iat: number; | 950 | readonly iat: number; |
| 951 | readonly accessToken?: string; | 951 | readonly accessToken?: string; |
| 952 | readonly jti?: string; | 952 | readonly jti?: string; |
| 953 | readonly privateKey?: NodeCrypto.KeyObject; | 953 | readonly privateKey?: KeyObject; |
| 954 | readonly publicJwk?: DpopPublicJwk; | 954 | readonly publicJwk?: DpopPublicJwk; |
| 955 | }) => { | 955 | }) => { |
| 956 | const keyPair = | 956 | const keyPair = |
| 957 | input.privateKey && input.publicJwk | 957 | input.privateKey && input.publicJwk |
| 958 | ? { privateKey: input.privateKey, publicJwk: input.publicJwk } | 958 | ? { privateKey: input.privateKey, publicJwk: input.publicJwk } |
| 959 | : (() => { | 959 | : (() => { |
| 960 | const { privateKey, publicKey } = NodeCrypto.generateKeyPairSync("ec", { | 960 | const { privateKey, publicKey } = generateKeyPairSync("ec", { |
| 961 | namedCurve: "P-256", | 961 | namedCurve: "P-256", |
| 962 | }); | 962 | }); |
| 963 | return { privateKey, publicJwk: publicKey.export({ format: "jwk" }) as DpopPublicJwk }; | 963 | return { privateKey, publicJwk: publicKey.export({ format: "jwk" }) as DpopPublicJwk }; |
| ... | @@ -978,7 +978,7 @@ const makeDpopProof = (input: { | ... | @@ -978,7 +978,7 @@ const makeDpopProof = (input: { |
| 978 | ...(input.accessToken ? { ath: computeDpopAccessTokenHash(input.accessToken) } : {}), | 978 | ...(input.accessToken ? { ath: computeDpopAccessTokenHash(input.accessToken) } : {}), |
| 979 | }), | 979 | }), |
| 980 | ).toString("base64url"); | 980 | ).toString("base64url"); |
| 981 | const signature = NodeCrypto.sign("sha256", Buffer.from(`${header}.${payload}`), { | 981 | const signature = sign("sha256", Buffer.from(`${header}.${payload}`), { |
| 982 | key: keyPair.privateKey, | 982 | key: keyPair.privateKey, |
| 983 | dsaEncoding: "ieee-p1363", | 983 | dsaEncoding: "ieee-p1363", |
| 984 | }).toString("base64url"); | 984 | }).toString("base64url"); |
| ... | @@ -1024,7 +1024,7 @@ const makeCloudMintCredentialRequest = (input: { | ... | @@ -1024,7 +1024,7 @@ const makeCloudMintCredentialRequest = (input: { |
| 1024 | const encodedPayload = Buffer.from(JSON.stringify(payload)).toString("base64url"); | 1024 | const encodedPayload = Buffer.from(JSON.stringify(payload)).toString("base64url"); |
| 1025 | const signingInput = `${header}.${encodedPayload}`; | 1025 | const signingInput = `${header}.${encodedPayload}`; |
| 1026 | return { | 1026 | return { |
| 1027 | proof: `${signingInput}.${NodeCrypto.sign(null, Buffer.from(signingInput), input.privateKey).toString("base64url")}`, | 1027 | proof: `${signingInput}.${sign(null, Buffer.from(signingInput), input.privateKey).toString("base64url")}`, |
| 1028 | }; | 1028 | }; |
| 1029 | }; | 1029 | }; |
| 1030 | 1030 | ||
| ... | @@ -1057,7 +1057,7 @@ const makeCloudEnvironmentHealthRequest = (input: { | ... | @@ -1057,7 +1057,7 @@ const makeCloudEnvironmentHealthRequest = (input: { |
| 1057 | const encodedPayload = Buffer.from(JSON.stringify(payload)).toString("base64url"); | 1057 | const encodedPayload = Buffer.from(JSON.stringify(payload)).toString("base64url"); |
| 1058 | const signingInput = `${header}.${encodedPayload}`; | 1058 | const signingInput = `${header}.${encodedPayload}`; |
| 1059 | return { | 1059 | return { |
| 1060 | proof: `${signingInput}.${NodeCrypto.sign(null, Buffer.from(signingInput), input.privateKey).toString("base64url")}`, | 1060 | proof: `${signingInput}.${sign(null, Buffer.from(signingInput), input.privateKey).toString("base64url")}`, |
| 1061 | }; | 1061 | }; |
| 1062 | }; | 1062 | }; |
| 1063 | 1063 | ||
| ... | @@ -2054,7 +2054,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { | ... | @@ -2054,7 +2054,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 2054 | Effect.gen(function* () { | 2054 | Effect.gen(function* () { |
| 2055 | yield* buildAppUnderTest(); | 2055 | yield* buildAppUnderTest(); |
| 2056 | 2056 | ||
| 2057 | const cloudKeyPair = NodeCrypto.generateKeyPairSync("ed25519", { | 2057 | const cloudKeyPair = generateKeyPairSync("ed25519", { |
| 2058 | privateKeyEncoding: { format: "pem", type: "pkcs8" }, | 2058 | privateKeyEncoding: { format: "pem", type: "pkcs8" }, |
| 2059 | publicKeyEncoding: { format: "pem", type: "spki" }, | 2059 | publicKeyEncoding: { format: "pem", type: "spki" }, |
| 2060 | }); | 2060 | }); |
| ... | @@ -2131,7 +2131,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { | ... | @@ -2131,7 +2131,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 2131 | Effect.gen(function* () { | 2131 | Effect.gen(function* () { |
| 2132 | yield* buildAppUnderTest(); | 2132 | yield* buildAppUnderTest(); |
| 2133 | 2133 | ||
| 2134 | const cloudKeyPair = NodeCrypto.generateKeyPairSync("ed25519", { | 2134 | const cloudKeyPair = generateKeyPairSync("ed25519", { |
| 2135 | privateKeyEncoding: { format: "pem", type: "pkcs8" }, | 2135 | privateKeyEncoding: { format: "pem", type: "pkcs8" }, |
| 2136 | publicKeyEncoding: { format: "pem", type: "spki" }, | 2136 | publicKeyEncoding: { format: "pem", type: "spki" }, |
| 2137 | }); | 2137 | }); |
| ... | @@ -2174,7 +2174,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { | ... | @@ -2174,7 +2174,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 2174 | Effect.gen(function* () { | 2174 | Effect.gen(function* () { |
| 2175 | yield* buildAppUnderTest(); | 2175 | yield* buildAppUnderTest(); |
| 2176 | 2176 | ||
| 2177 | const cloudKeyPair = NodeCrypto.generateKeyPairSync("ed25519", { | 2177 | const cloudKeyPair = generateKeyPairSync("ed25519", { |
| 2178 | privateKeyEncoding: { format: "pem", type: "pkcs8" }, | 2178 | privateKeyEncoding: { format: "pem", type: "pkcs8" }, |
| 2179 | publicKeyEncoding: { format: "pem", type: "spki" }, | 2179 | publicKeyEncoding: { format: "pem", type: "spki" }, |
| 2180 | }); | 2180 | }); |
| ... | @@ -2268,7 +2268,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { | ... | @@ -2268,7 +2268,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 2268 | }, | 2268 | }, |
| 2269 | }); | 2269 | }); |
| 2270 | 2270 | ||
| 2271 | const cloudKeyPair = NodeCrypto.generateKeyPairSync("ed25519", { | 2271 | const cloudKeyPair = generateKeyPairSync("ed25519", { |
| 2272 | privateKeyEncoding: { format: "pem", type: "pkcs8" }, | 2272 | privateKeyEncoding: { format: "pem", type: "pkcs8" }, |
| 2273 | publicKeyEncoding: { format: "pem", type: "spki" }, | 2273 | publicKeyEncoding: { format: "pem", type: "spki" }, |
| 2274 | }); | 2274 | }); |
| ... | @@ -2345,7 +2345,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { | ... | @@ -2345,7 +2345,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 2345 | Effect.gen(function* () { | 2345 | Effect.gen(function* () { |
| 2346 | yield* buildAppUnderTest(); | 2346 | yield* buildAppUnderTest(); |
| 2347 | 2347 | ||
| 2348 | const cloudKeyPair = NodeCrypto.generateKeyPairSync("ed25519", { | 2348 | const cloudKeyPair = generateKeyPairSync("ed25519", { |
| 2349 | privateKeyEncoding: { format: "pem", type: "pkcs8" }, | 2349 | privateKeyEncoding: { format: "pem", type: "pkcs8" }, |
| 2350 | publicKeyEncoding: { format: "pem", type: "spki" }, | 2350 | publicKeyEncoding: { format: "pem", type: "spki" }, |
| 2351 | }); | 2351 | }); |
| ... | @@ -2404,7 +2404,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { | ... | @@ -2404,7 +2404,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 2404 | Effect.gen(function* () { | 2404 | Effect.gen(function* () { |
| 2405 | yield* buildAppUnderTest(); | 2405 | yield* buildAppUnderTest(); |
| 2406 | 2406 | ||
| 2407 | const cloudKeyPair = NodeCrypto.generateKeyPairSync("ed25519", { | 2407 | const cloudKeyPair = generateKeyPairSync("ed25519", { |
| 2408 | privateKeyEncoding: { format: "pem", type: "pkcs8" }, | 2408 | privateKeyEncoding: { format: "pem", type: "pkcs8" }, |
| 2409 | publicKeyEncoding: { format: "pem", type: "spki" }, | 2409 | publicKeyEncoding: { format: "pem", type: "spki" }, |
| 2410 | }); | 2410 | }); |
| ... | @@ -2463,7 +2463,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { | ... | @@ -2463,7 +2463,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 2463 | Effect.gen(function* () { | 2463 | Effect.gen(function* () { |
| 2464 | yield* buildAppUnderTest(); | 2464 | yield* buildAppUnderTest(); |
| 2465 | 2465 | ||
| 2466 | const cloudKeyPair = NodeCrypto.generateKeyPairSync("ed25519", { | 2466 | const cloudKeyPair = generateKeyPairSync("ed25519", { |
| 2467 | privateKeyEncoding: { format: "pem", type: "pkcs8" }, | 2467 | privateKeyEncoding: { format: "pem", type: "pkcs8" }, |
| 2468 | publicKeyEncoding: { format: "pem", type: "spki" }, | 2468 | publicKeyEncoding: { format: "pem", type: "spki" }, |
| 2469 | }); | 2469 | }); |
| ... | @@ -2523,7 +2523,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { | ... | @@ -2523,7 +2523,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 2523 | Effect.gen(function* () { | 2523 | Effect.gen(function* () { |
| 2524 | yield* buildAppUnderTest(); | 2524 | yield* buildAppUnderTest(); |
| 2525 | 2525 | ||
| 2526 | const cloudKeyPair = NodeCrypto.generateKeyPairSync("ed25519", { | 2526 | const cloudKeyPair = generateKeyPairSync("ed25519", { |
| 2527 | privateKeyEncoding: { format: "pem", type: "pkcs8" }, | 2527 | privateKeyEncoding: { format: "pem", type: "pkcs8" }, |
| 2528 | publicKeyEncoding: { format: "pem", type: "spki" }, | 2528 | publicKeyEncoding: { format: "pem", type: "spki" }, |
| 2529 | }); | 2529 | }); |
| ... | @@ -2584,7 +2584,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { | ... | @@ -2584,7 +2584,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 2584 | Effect.gen(function* () { | 2584 | Effect.gen(function* () { |
| 2585 | yield* buildAppUnderTest(); | 2585 | yield* buildAppUnderTest(); |
| 2586 | 2586 | ||
| 2587 | const cloudKeyPair = NodeCrypto.generateKeyPairSync("ed25519", { | 2587 | const cloudKeyPair = generateKeyPairSync("ed25519", { |
| 2588 | privateKeyEncoding: { format: "pem", type: "pkcs8" }, | 2588 | privateKeyEncoding: { format: "pem", type: "pkcs8" }, |
| 2589 | publicKeyEncoding: { format: "pem", type: "spki" }, | 2589 | publicKeyEncoding: { format: "pem", type: "spki" }, |
| 2590 | }); | 2590 | }); |
| ... | @@ -2664,7 +2664,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { | ... | @@ -2664,7 +2664,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 2664 | }, | 2664 | }, |
| 2665 | }); | 2665 | }); |
| 2666 | 2666 | ||
| 2667 | const cloudKeyPair = NodeCrypto.generateKeyPairSync("ed25519", { | 2667 | const cloudKeyPair = generateKeyPairSync("ed25519", { |
| 2668 | privateKeyEncoding: { format: "pem", type: "pkcs8" }, | 2668 | privateKeyEncoding: { format: "pem", type: "pkcs8" }, |
| 2669 | publicKeyEncoding: { format: "pem", type: "spki" }, | 2669 | publicKeyEncoding: { format: "pem", type: "spki" }, |
| 2670 | }); | 2670 | }); |
| ... | @@ -2733,7 +2733,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { | ... | @@ -2733,7 +2733,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 2733 | Effect.gen(function* () { | 2733 | Effect.gen(function* () { |
| 2734 | yield* buildAppUnderTest(); | 2734 | yield* buildAppUnderTest(); |
| 2735 | 2735 | ||
| 2736 | const cloudKeyPair = NodeCrypto.generateKeyPairSync("ed25519", { | 2736 | const cloudKeyPair = generateKeyPairSync("ed25519", { |
| 2737 | privateKeyEncoding: { format: "pem", type: "pkcs8" }, | 2737 | privateKeyEncoding: { format: "pem", type: "pkcs8" }, |
| 2738 | publicKeyEncoding: { format: "pem", type: "spki" }, | 2738 | publicKeyEncoding: { format: "pem", type: "spki" }, |
| 2739 | }); | 2739 | }); |
| ... | @@ -2800,7 +2800,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { | ... | @@ -2800,7 +2800,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 2800 | Effect.gen(function* () { | 2800 | Effect.gen(function* () { |
| 2801 | yield* buildAppUnderTest(); | 2801 | yield* buildAppUnderTest(); |
| 2802 | 2802 | ||
| 2803 | const cloudKeyPair = NodeCrypto.generateKeyPairSync("ed25519", { | 2803 | const cloudKeyPair = generateKeyPairSync("ed25519", { |
| 2804 | privateKeyEncoding: { format: "pem", type: "pkcs8" }, | 2804 | privateKeyEncoding: { format: "pem", type: "pkcs8" }, |
| 2805 | publicKeyEncoding: { format: "pem", type: "spki" }, | 2805 | publicKeyEncoding: { format: "pem", type: "spki" }, |
| 2806 | }); | 2806 | }); |
| ... | @@ -2851,7 +2851,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { | ... | @@ -2851,7 +2851,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 2851 | Effect.gen(function* () { | 2851 | Effect.gen(function* () { |
| 2852 | yield* buildAppUnderTest(); | 2852 | yield* buildAppUnderTest(); |
| 2853 | 2853 | ||
| 2854 | const cloudKeyPair = NodeCrypto.generateKeyPairSync("ed25519", { | 2854 | const cloudKeyPair = generateKeyPairSync("ed25519", { |
| 2855 | privateKeyEncoding: { format: "pem", type: "pkcs8" }, | 2855 | privateKeyEncoding: { format: "pem", type: "pkcs8" }, |
| 2856 | publicKeyEncoding: { format: "pem", type: "spki" }, | 2856 | publicKeyEncoding: { format: "pem", type: "spki" }, |
| 2857 | }); | 2857 | }); |
| ... | @@ -2902,7 +2902,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { | ... | @@ -2902,7 +2902,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 2902 | Effect.gen(function* () { | 2902 | Effect.gen(function* () { |
| 2903 | yield* buildAppUnderTest(); | 2903 | yield* buildAppUnderTest(); |
| 2904 | 2904 | ||
| 2905 | const cloudKeyPair = NodeCrypto.generateKeyPairSync("ed25519", { | 2905 | const cloudKeyPair = generateKeyPairSync("ed25519", { |
| 2906 | privateKeyEncoding: { format: "pem", type: "pkcs8" }, | 2906 | privateKeyEncoding: { format: "pem", type: "pkcs8" }, |
| 2907 | publicKeyEncoding: { format: "pem", type: "spki" }, | 2907 | publicKeyEncoding: { format: "pem", type: "spki" }, |
| 2908 | }); | 2908 | }); |
| ... | @@ -2967,7 +2967,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { | ... | @@ -2967,7 +2967,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 2967 | Effect.gen(function* () { | 2967 | Effect.gen(function* () { |
| 2968 | yield* buildAppUnderTest(); | 2968 | yield* buildAppUnderTest(); |
| 2969 | 2969 | ||
| 2970 | const cloudKeyPair = NodeCrypto.generateKeyPairSync("ed25519", { | 2970 | const cloudKeyPair = generateKeyPairSync("ed25519", { |
| 2971 | privateKeyEncoding: { format: "pem", type: "pkcs8" }, | 2971 | privateKeyEncoding: { format: "pem", type: "pkcs8" }, |
| 2972 | publicKeyEncoding: { format: "pem", type: "spki" }, | 2972 | publicKeyEncoding: { format: "pem", type: "spki" }, |
| 2973 | }); | 2973 | }); |
| ... | @@ -3017,7 +3017,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { | ... | @@ -3017,7 +3017,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 3017 | Effect.gen(function* () { | 3017 | Effect.gen(function* () { |
| 3018 | yield* buildAppUnderTest(); | 3018 | yield* buildAppUnderTest(); |
| 3019 | 3019 | ||
| 3020 | const cloudKeyPair = NodeCrypto.generateKeyPairSync("ed25519", { | 3020 | const cloudKeyPair = generateKeyPairSync("ed25519", { |
| 3021 | privateKeyEncoding: { format: "pem", type: "pkcs8" }, | 3021 | privateKeyEncoding: { format: "pem", type: "pkcs8" }, |
| 3022 | publicKeyEncoding: { format: "pem", type: "spki" }, | 3022 | publicKeyEncoding: { format: "pem", type: "spki" }, |
| 3023 | }); | 3023 | }); |
| ... | @@ -4447,10 +4447,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { | ... | @@ -4447,10 +4447,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 4447 | 4447 | ||
| 4448 | assertTrue(result._tag === "Failure"); | 4448 | assertTrue(result._tag === "Failure"); |
| 4449 | assertTrue(result.failure._tag === "ProjectSearchEntriesError"); | 4449 | assertTrue(result.failure._tag === "ProjectSearchEntriesError"); |
| 4450 | assertInclude( | 4450 | assert.equal(result.failure.message, "Failed to search workspace entries."); |
| 4451 | result.failure.message, | ||
| 4452 | "Workspace root does not exist: /definitely/not/a/real/workspace/path", | ||
| 4453 | ); | ||
| 4454 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | 4451 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), |
| 4455 | ); | 4452 | ); |
| 4456 | 4453 | ||
| ... | @@ -6096,13 +6093,16 @@ it.layer(NodeServices.layer)("server router seam", (it) => { | ... | @@ -6096,13 +6093,16 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 6096 | ); | 6093 | ); |
| 6097 | const runForThread = vi.fn( | 6094 | const runForThread = vi.fn( |
| 6098 | ( | 6095 | ( |
| 6099 | _: Parameters< | 6096 | input: Parameters< |
| 6100 | ProjectSetupScriptRunner.ProjectSetupScriptRunner["Service"]["runForThread"] | 6097 | ProjectSetupScriptRunner.ProjectSetupScriptRunner["Service"]["runForThread"] |
| 6101 | >[0], | 6098 | >[0], |
| 6102 | ) => | 6099 | ) => |
| 6103 | Effect.fail( | 6100 | Effect.fail( |
| 6104 | new ProjectSetupScriptRunner.ProjectSetupScriptRunnerError({ | 6101 | new ProjectSetupScriptRunner.ProjectSetupScriptOperationError({ |
| 6105 | message: "pty unavailable", | 6102 | threadId: input.threadId, |
| 6103 | worktreePath: input.worktreePath, | ||
| 6104 | operation: "openTerminal", | ||
| 6105 | cause: new Error("pty unavailable"), | ||
| 6106 | }), | 6106 | }), |
| 6107 | ), | 6107 | ), |
| 6108 | ); | 6108 | ); |
| ... | @@ -6177,7 +6177,8 @@ it.layer(NodeServices.layer)("server router seam", (it) => { | ... | @@ -6177,7 +6177,8 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 6177 | ); | 6177 | ); |
| 6178 | assert.equal(setupFailureActivity?.activity.kind, "setup-script.failed"); | 6178 | assert.equal(setupFailureActivity?.activity.kind, "setup-script.failed"); |
| 6179 | assert.deepEqual(setupFailureActivity?.activity.payload, { | 6179 | assert.deepEqual(setupFailureActivity?.activity.payload, { |
| 6180 | detail: "pty unavailable", | 6180 | detail: |
| 6181 | "Project setup script operation 'openTerminal' failed for thread 'thread-bootstrap-setup-failure' in '/tmp/bootstrap-worktree'.", | ||
| 6181 | worktreePath: "/tmp/bootstrap-worktree", | 6182 | worktreePath: "/tmp/bootstrap-worktree", |
| 6182 | }); | 6183 | }); |
| 6183 | assertTrue(dispatchedCommands.every((command) => command.type !== "thread.delete")); | 6184 | assertTrue(dispatchedCommands.every((command) => command.type !== "thread.delete")); |
apps/server/src/server.ts+15-15| ... | @@ -52,11 +52,11 @@ import * as AgentAwarenessRelay from "./relay/AgentAwarenessRelay.ts"; | ... | @@ -52,11 +52,11 @@ import * as AgentAwarenessRelay from "./relay/AgentAwarenessRelay.ts"; |
| 52 | import { hasCloudPublicConfig } from "./cloud/publicConfig.ts"; | 52 | import { hasCloudPublicConfig } from "./cloud/publicConfig.ts"; |
| 53 | import { ProviderRegistryLive } from "./provider/Layers/ProviderRegistry.ts"; | 53 | import { ProviderRegistryLive } from "./provider/Layers/ProviderRegistry.ts"; |
| 54 | import * as ServerSettings from "./serverSettings.ts"; | 54 | import * as ServerSettings from "./serverSettings.ts"; |
| 55 | import { ProjectFaviconResolverLive } from "./project/Layers/ProjectFaviconResolver.ts"; | 55 | import * as ProjectFaviconResolver from "./project/ProjectFaviconResolver.ts"; |
| 56 | import { RepositoryIdentityResolverLive } from "./project/Layers/RepositoryIdentityResolver.ts"; | 56 | import * as RepositoryIdentityResolver from "./project/RepositoryIdentityResolver.ts"; |
| 57 | import * as WorkspaceEntries from "./workspace/WorkspaceEntries.ts"; | 57 | import * as WorkspaceEntries from "./workspace/WorkspaceEntries.ts"; |
| 58 | import { WorkspaceFileSystemLive } from "./workspace/Layers/WorkspaceFileSystem.ts"; | 58 | import * as WorkspaceFileSystem from "./workspace/WorkspaceFileSystem.ts"; |
| 59 | import { WorkspacePathsLive } from "./workspace/Layers/WorkspacePaths.ts"; | 59 | import * as WorkspacePaths from "./workspace/WorkspacePaths.ts"; |
| 60 | import * as GitVcsDriver from "./vcs/GitVcsDriver.ts"; | 60 | import * as GitVcsDriver from "./vcs/GitVcsDriver.ts"; |
| 61 | import * as VcsDriverRegistry from "./vcs/VcsDriverRegistry.ts"; | 61 | import * as VcsDriverRegistry from "./vcs/VcsDriverRegistry.ts"; |
| 62 | import * as VcsProjectConfig from "./vcs/VcsProjectConfig.ts"; | 62 | import * as VcsProjectConfig from "./vcs/VcsProjectConfig.ts"; |
| ... | @@ -67,9 +67,9 @@ import * as GitWorkflowService from "./git/GitWorkflowService.ts"; | ... | @@ -67,9 +67,9 @@ import * as GitWorkflowService from "./git/GitWorkflowService.ts"; |
| 67 | import * as ReviewService from "./review/ReviewService.ts"; | 67 | import * as ReviewService from "./review/ReviewService.ts"; |
| 68 | import * as SourceControlProviderRegistry from "./sourceControl/SourceControlProviderRegistry.ts"; | 68 | import * as SourceControlProviderRegistry from "./sourceControl/SourceControlProviderRegistry.ts"; |
| 69 | import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts"; | 69 | import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts"; |
| 70 | import { ProjectSetupScriptRunnerLive } from "./project/Layers/ProjectSetupScriptRunner.ts"; | 70 | import * as ProjectSetupScriptRunner from "./project/ProjectSetupScriptRunner.ts"; |
| 71 | import { ObservabilityLive } from "./observability/Layers/Observability.ts"; | 71 | import { ObservabilityLive } from "./observability/Layers/Observability.ts"; |
| 72 | import { ServerEnvironmentLive } from "./environment/Layers/ServerEnvironment.ts"; | 72 | import * as ServerEnvironment from "./environment/ServerEnvironment.ts"; |
| 73 | import { authHttpApiLayer, environmentAuthenticatedAuthLayer } from "./auth/http.ts"; | 73 | import { authHttpApiLayer, environmentAuthenticatedAuthLayer } from "./auth/http.ts"; |
| 74 | import * as ServerSecretStore from "./auth/ServerSecretStore.ts"; | 74 | import * as ServerSecretStore from "./auth/ServerSecretStore.ts"; |
| 75 | import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; | 75 | import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; |
| ... | @@ -195,7 +195,7 @@ const SourceControlProviderRegistryLayerLive = SourceControlProviderRegistry.lay | ... | @@ -195,7 +195,7 @@ const SourceControlProviderRegistryLayerLive = SourceControlProviderRegistry.lay |
| 195 | ); | 195 | ); |
| 196 | 196 | ||
| 197 | const GitManagerLayerLive = GitManager.layer.pipe( | 197 | const GitManagerLayerLive = GitManager.layer.pipe( |
| 198 | Layer.provideMerge(ProjectSetupScriptRunnerLive), | 198 | Layer.provideMerge(ProjectSetupScriptRunner.layer), |
| 199 | Layer.provideMerge(GitVcsDriver.layer), | 199 | Layer.provideMerge(GitVcsDriver.layer), |
| 200 | Layer.provideMerge(SourceControlProviderRegistryLayerLive), | 200 | Layer.provideMerge(SourceControlProviderRegistryLayerLive), |
| 201 | Layer.provideMerge(TextGeneration.layer), | 201 | Layer.provideMerge(TextGeneration.layer), |
| ... | @@ -248,21 +248,21 @@ const PreviewLayerLive = Layer.empty.pipe( | ... | @@ -248,21 +248,21 @@ const PreviewLayerLive = Layer.empty.pipe( |
| 248 | Layer.provideMerge(PortScannerLayerLive), | 248 | Layer.provideMerge(PortScannerLayerLive), |
| 249 | ); | 249 | ); |
| 250 | 250 | ||
| 251 | const WorkspaceEntriesLayerLive = WorkspaceEntries.layer.pipe(Layer.provide(WorkspacePathsLive)); | 251 | const WorkspaceEntriesLayerLive = WorkspaceEntries.layer.pipe(Layer.provide(WorkspacePaths.layer)); |
| 252 | 252 | ||
| 253 | const WorkspaceFileSystemLayerLive = WorkspaceFileSystemLive.pipe( | 253 | const WorkspaceFileSystemLayerLive = WorkspaceFileSystem.layer.pipe( |
| 254 | Layer.provide(WorkspacePathsLive), | 254 | Layer.provide(WorkspacePaths.layer), |
| 255 | Layer.provide(WorkspaceEntriesLayerLive), | 255 | Layer.provide(WorkspaceEntriesLayerLive), |
| 256 | ); | 256 | ); |
| 257 | 257 | ||
| 258 | const WorkspaceLayerLive = Layer.mergeAll( | 258 | const WorkspaceLayerLive = Layer.mergeAll( |
| 259 | WorkspacePathsLive, | 259 | WorkspacePaths.layer, |
| 260 | WorkspaceEntriesLayerLive, | 260 | WorkspaceEntriesLayerLive, |
| 261 | WorkspaceFileSystemLayerLive, | 261 | WorkspaceFileSystemLayerLive, |
| 262 | ); | 262 | ); |
| 263 | 263 | ||
| 264 | const ProjectFaviconResolverLayerLive = ProjectFaviconResolverLive.pipe( | 264 | const ProjectFaviconResolverLayerLive = ProjectFaviconResolver.layer.pipe( |
| 265 | Layer.provide(WorkspacePathsLive), | 265 | Layer.provide(WorkspacePaths.layer), |
| 266 | ); | 266 | ); |
| 267 | 267 | ||
| 268 | const AuthLayerLive = EnvironmentAuth.layer.pipe( | 268 | const AuthLayerLive = EnvironmentAuth.layer.pipe( |
| ... | @@ -315,8 +315,8 @@ const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe( | ... | @@ -315,8 +315,8 @@ const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe( |
| 315 | Layer.provideMerge(ServerSettings.layer.pipe(Layer.provide(ServerSecretStore.layer))), | 315 | Layer.provideMerge(ServerSettings.layer.pipe(Layer.provide(ServerSecretStore.layer))), |
| 316 | Layer.provideMerge(WorkspaceLayerLive), | 316 | Layer.provideMerge(WorkspaceLayerLive), |
| 317 | Layer.provideMerge(ProjectFaviconResolverLayerLive), | 317 | Layer.provideMerge(ProjectFaviconResolverLayerLive), |
| 318 | Layer.provideMerge(RepositoryIdentityResolverLive), | 318 | Layer.provideMerge(RepositoryIdentityResolver.layer), |
| 319 | Layer.provideMerge(ServerEnvironmentLive), | 319 | Layer.provideMerge(ServerEnvironment.layer), |
| 320 | Layer.provideMerge(AuthLayerLive), | 320 | Layer.provideMerge(AuthLayerLive), |
| 321 | Layer.provideMerge(ServerSecretStore.layer), | 321 | Layer.provideMerge(ServerSecretStore.layer), |
| 322 | Layer.provideMerge( | 322 | Layer.provideMerge( |
apps/server/src/serverRuntimeStartup.test.ts+4-1| ... | @@ -57,7 +57,10 @@ it.effect("enqueueCommand fails queued work when readiness fails", () => | ... | @@ -57,7 +57,10 @@ it.effect("enqueueCommand fails queued work when readiness fails", () => |
| 57 | 57 | ||
| 58 | yield* commandGate.failCommandReady( | 58 | yield* commandGate.failCommandReady( |
| 59 | new ServerRuntimeStartup.ServerRuntimeStartupError({ | 59 | new ServerRuntimeStartup.ServerRuntimeStartupError({ |
| 60 | stage: "command-readiness", | 60 | mode: "web", |
| 61 | host: "127.0.0.1", | ||
| 62 | port: 3773, | ||
| 63 | cause: new Error("test startup failure"), | ||
| 61 | }), | 64 | }), |
| 62 | ); | 65 | ); |
| 63 | 66 |
apps/server/src/serverRuntimeStartup.ts+10-9| ... | @@ -30,8 +30,8 @@ import * as ProjectionSnapshotQuery from "./orchestration/Services/ProjectionSna | ... | @@ -30,8 +30,8 @@ import * as ProjectionSnapshotQuery from "./orchestration/Services/ProjectionSna |
| 30 | import * as OrchestrationReactor from "./orchestration/Services/OrchestrationReactor.ts"; | 30 | import * as OrchestrationReactor from "./orchestration/Services/OrchestrationReactor.ts"; |
| 31 | import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts"; | 31 | import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts"; |
| 32 | import * as ServerSettings from "./serverSettings.ts"; | 32 | import * as ServerSettings from "./serverSettings.ts"; |
| 33 | import * as ServerEnvironment from "./environment/Services/ServerEnvironment.ts"; | ||
| 34 | import * as AnalyticsService from "./telemetry/AnalyticsService.ts"; | 33 | import * as AnalyticsService from "./telemetry/AnalyticsService.ts"; |
| 34 | import * as ServerEnvironment from "./environment/ServerEnvironment.ts"; | ||
| 35 | import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; | 35 | import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; |
| 36 | import * as ProviderSessionReaper from "./provider/Services/ProviderSessionReaper.ts"; | 36 | import * as ProviderSessionReaper from "./provider/Services/ProviderSessionReaper.ts"; |
| 37 | import { | 37 | import { |
| ... | @@ -44,15 +44,14 @@ import { | ... | @@ -44,15 +44,14 @@ import { |
| 44 | export class ServerRuntimeStartupError extends Schema.TaggedErrorClass<ServerRuntimeStartupError>()( | 44 | export class ServerRuntimeStartupError extends Schema.TaggedErrorClass<ServerRuntimeStartupError>()( |
| 45 | "ServerRuntimeStartupError", | 45 | "ServerRuntimeStartupError", |
| 46 | { | 46 | { |
| 47 | stage: Schema.Literal("command-readiness"), | 47 | mode: ServerConfig.RuntimeMode, |
| 48 | cause: Schema.optional(Schema.Defect()), | 48 | host: Schema.NullOr(Schema.String), |
| 49 | port: Schema.Number, | ||
| 50 | cause: Schema.Defect(), | ||
| 49 | }, | 51 | }, |
| 50 | ) { | 52 | ) { |
| 51 | override get message(): string { | 53 | override get message(): string { |
| 52 | switch (this.stage) { | 54 | return "Server runtime startup failed before command readiness."; |
| 53 | case "command-readiness": | ||
| 54 | return "Server runtime startup failed before command readiness."; | ||
| 55 | } | ||
| 56 | } | 55 | } |
| 57 | } | 56 | } |
| 58 | 57 | ||
| ... | @@ -289,7 +288,7 @@ const runStartupPhase = <A, E, R>(phase: string, effect: Effect.Effect<A, E, R>) | ... | @@ -289,7 +288,7 @@ const runStartupPhase = <A, E, R>(phase: string, effect: Effect.Effect<A, E, R>) |
| 289 | Effect.withSpan(`server.startup.${phase}`), | 288 | Effect.withSpan(`server.startup.${phase}`), |
| 290 | ); | 289 | ); |
| 291 | 290 | ||
| 292 | const make = Effect.gen(function* () { | 291 | export const make = Effect.gen(function* () { |
| 293 | const serverConfig = yield* ServerConfig.ServerConfig; | 292 | const serverConfig = yield* ServerConfig.ServerConfig; |
| 294 | const keybindings = yield* Keybindings.Keybindings; | 293 | const keybindings = yield* Keybindings.Keybindings; |
| 295 | const orchestrationReactor = yield* OrchestrationReactor.OrchestrationReactor; | 294 | const orchestrationReactor = yield* OrchestrationReactor.OrchestrationReactor; |
| ... | @@ -417,7 +416,9 @@ const make = Effect.gen(function* () { | ... | @@ -417,7 +416,9 @@ const make = Effect.gen(function* () { |
| 417 | const startupExit = yield* Effect.exit(startup); | 416 | const startupExit = yield* Effect.exit(startup); |
| 418 | if (Exit.isFailure(startupExit)) { | 417 | if (Exit.isFailure(startupExit)) { |
| 419 | const error = new ServerRuntimeStartupError({ | 418 | const error = new ServerRuntimeStartupError({ |
| 420 | stage: "command-readiness", | 419 | mode: serverConfig.mode, |
| 420 | host: serverConfig.host ?? null, | ||
| 421 | port: serverConfig.port, | ||
| 421 | cause: startupExit.cause, | 422 | cause: startupExit.cause, |
| 422 | }); | 423 | }); |
| 423 | yield* Effect.logError("server runtime startup failed", { cause: startupExit.cause }); | 424 | yield* Effect.logError("server runtime startup failed", { cause: startupExit.cause }); |
apps/server/src/workspace/Layers/WorkspaceFileSystem.test.ts deleted-188| ... | @@ -1,188 +0,0 @@ | ||
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| 2 | import { it, describe, expect } from "@effect/vitest"; | ||
| 3 | import * as Effect from "effect/Effect"; | ||
| 4 | import * as FileSystem from "effect/FileSystem"; | ||
| 5 | import * as Layer from "effect/Layer"; | ||
| 6 | import * as Path from "effect/Path"; | ||
| 7 | |||
| 8 | import { ServerConfig } from "../../config.ts"; | ||
| 9 | import * as VcsDriverRegistry from "../../vcs/VcsDriverRegistry.ts"; | ||
| 10 | import * as VcsProcess from "../../vcs/VcsProcess.ts"; | ||
| 11 | import * as WorkspaceEntries from "../WorkspaceEntries.ts"; | ||
| 12 | import { WorkspaceFileSystem } from "../Services/WorkspaceFileSystem.ts"; | ||
| 13 | import { WorkspaceFileSystemLive } from "./WorkspaceFileSystem.ts"; | ||
| 14 | import { WorkspacePathsLive } from "./WorkspacePaths.ts"; | ||
| 15 | |||
| 16 | const ProjectLayer = WorkspaceFileSystemLive.pipe( | ||
| 17 | Layer.provide(WorkspacePathsLive), | ||
| 18 | Layer.provide(WorkspaceEntries.layer.pipe(Layer.provide(WorkspacePathsLive))), | ||
| 19 | ); | ||
| 20 | |||
| 21 | const TestLayer = Layer.empty.pipe( | ||
| 22 | Layer.provideMerge(ProjectLayer), | ||
| 23 | Layer.provideMerge(WorkspaceEntries.layer.pipe(Layer.provide(WorkspacePathsLive))), | ||
| 24 | Layer.provideMerge(WorkspacePathsLive), | ||
| 25 | Layer.provideMerge(VcsDriverRegistry.layer.pipe(Layer.provide(VcsProcess.layer))), | ||
| 26 | Layer.provide( | ||
| 27 | ServerConfig.layerTest(process.cwd(), { | ||
| 28 | prefix: "t3-workspace-files-test-", | ||
| 29 | }), | ||
| 30 | ), | ||
| 31 | Layer.provideMerge(NodeServices.layer), | ||
| 32 | ); | ||
| 33 | |||
| 34 | const makeTempDir = Effect.gen(function* () { | ||
| 35 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 36 | return yield* fileSystem.makeTempDirectoryScoped({ | ||
| 37 | prefix: "t3code-workspace-files-", | ||
| 38 | }); | ||
| 39 | }); | ||
| 40 | |||
| 41 | const writeTextFile = Effect.fn("writeTextFile")(function* ( | ||
| 42 | cwd: string, | ||
| 43 | relativePath: string, | ||
| 44 | contents = "", | ||
| 45 | ) { | ||
| 46 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 47 | const path = yield* Path.Path; | ||
| 48 | const absolutePath = path.join(cwd, relativePath); | ||
| 49 | yield* fileSystem | ||
| 50 | .makeDirectory(path.dirname(absolutePath), { recursive: true }) | ||
| 51 | .pipe(Effect.orDie); | ||
| 52 | yield* fileSystem.writeFileString(absolutePath, contents).pipe(Effect.orDie); | ||
| 53 | }); | ||
| 54 | |||
| 55 | it.layer(TestLayer, { excludeTestServices: true })("WorkspaceFileSystemLive", (it) => { | ||
| 56 | describe("readFile", () => { | ||
| 57 | it.effect("reads UTF-8 files relative to the workspace root", () => | ||
| 58 | Effect.gen(function* () { | ||
| 59 | const workspaceFileSystem = yield* WorkspaceFileSystem; | ||
| 60 | const cwd = yield* makeTempDir; | ||
| 61 | yield* writeTextFile(cwd, "src/index.ts", "export const answer = 42;\n"); | ||
| 62 | |||
| 63 | const result = yield* workspaceFileSystem.readFile({ | ||
| 64 | cwd, | ||
| 65 | relativePath: "src/index.ts", | ||
| 66 | }); | ||
| 67 | |||
| 68 | expect(result).toEqual({ | ||
| 69 | relativePath: "src/index.ts", | ||
| 70 | contents: "export const answer = 42;\n", | ||
| 71 | byteLength: 26, | ||
| 72 | truncated: false, | ||
| 73 | }); | ||
| 74 | }), | ||
| 75 | ); | ||
| 76 | |||
| 77 | it.effect("rejects reads outside the workspace root", () => | ||
| 78 | Effect.gen(function* () { | ||
| 79 | const workspaceFileSystem = yield* WorkspaceFileSystem; | ||
| 80 | const cwd = yield* makeTempDir; | ||
| 81 | |||
| 82 | const error = yield* workspaceFileSystem | ||
| 83 | .readFile({ cwd, relativePath: "../escape.md" }) | ||
| 84 | .pipe(Effect.flip); | ||
| 85 | |||
| 86 | expect(error.message).toContain( | ||
| 87 | "Workspace file path must be relative to the project root: ../escape.md", | ||
| 88 | ); | ||
| 89 | }), | ||
| 90 | ); | ||
| 91 | |||
| 92 | it.effect("rejects symlinks that resolve outside the workspace root", () => | ||
| 93 | Effect.gen(function* () { | ||
| 94 | const workspaceFileSystem = yield* WorkspaceFileSystem; | ||
| 95 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 96 | const path = yield* Path.Path; | ||
| 97 | const cwd = yield* makeTempDir; | ||
| 98 | const outsideDir = yield* makeTempDir; | ||
| 99 | yield* writeTextFile(outsideDir, "secret.txt", "outside\n"); | ||
| 100 | yield* fileSystem.symlink( | ||
| 101 | path.join(outsideDir, "secret.txt"), | ||
| 102 | path.join(cwd, "linked-secret.txt"), | ||
| 103 | ); | ||
| 104 | |||
| 105 | const error = yield* workspaceFileSystem | ||
| 106 | .readFile({ cwd, relativePath: "linked-secret.txt" }) | ||
| 107 | .pipe(Effect.flip); | ||
| 108 | |||
| 109 | expect(error.message).toContain("resolves outside the project root"); | ||
| 110 | }), | ||
| 111 | ); | ||
| 112 | }); | ||
| 113 | |||
| 114 | describe("writeFile", () => { | ||
| 115 | it.effect("writes files relative to the workspace root", () => | ||
| 116 | Effect.gen(function* () { | ||
| 117 | const workspaceFileSystem = yield* WorkspaceFileSystem; | ||
| 118 | const cwd = yield* makeTempDir; | ||
| 119 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 120 | const path = yield* Path.Path; | ||
| 121 | const result = yield* workspaceFileSystem.writeFile({ | ||
| 122 | cwd, | ||
| 123 | relativePath: "plans/effect-rpc.md", | ||
| 124 | contents: "# Plan\n", | ||
| 125 | }); | ||
| 126 | const saved = yield* fileSystem | ||
| 127 | .readFileString(path.join(cwd, "plans/effect-rpc.md")) | ||
| 128 | .pipe(Effect.orDie); | ||
| 129 | |||
| 130 | expect(result).toEqual({ relativePath: "plans/effect-rpc.md" }); | ||
| 131 | expect(saved).toBe("# Plan\n"); | ||
| 132 | }), | ||
| 133 | ); | ||
| 134 | |||
| 135 | it.effect("invalidates workspace entry search cache after writes", () => | ||
| 136 | Effect.gen(function* () { | ||
| 137 | const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; | ||
| 138 | const workspaceFileSystem = yield* WorkspaceFileSystem; | ||
| 139 | const cwd = yield* makeTempDir; | ||
| 140 | yield* writeTextFile(cwd, "src/existing.ts", "export {};\n"); | ||
| 141 | |||
| 142 | const beforeWrite = yield* workspaceEntries.list({ cwd }); | ||
| 143 | expect(beforeWrite.entries.some((entry) => entry.path === "plans/effect-rpc.md")).toBe( | ||
| 144 | false, | ||
| 145 | ); | ||
| 146 | |||
| 147 | yield* workspaceFileSystem.writeFile({ | ||
| 148 | cwd, | ||
| 149 | relativePath: "plans/effect-rpc.md", | ||
| 150 | contents: "# Plan\n", | ||
| 151 | }); | ||
| 152 | |||
| 153 | const afterWrite = yield* workspaceEntries.list({ cwd }); | ||
| 154 | expect(afterWrite.entries).toEqual( | ||
| 155 | expect.arrayContaining([expect.objectContaining({ path: "plans/effect-rpc.md" })]), | ||
| 156 | ); | ||
| 157 | expect(afterWrite.truncated).toBe(false); | ||
| 158 | }), | ||
| 159 | ); | ||
| 160 | |||
| 161 | it.effect("rejects writes outside the workspace root", () => | ||
| 162 | Effect.gen(function* () { | ||
| 163 | const workspaceFileSystem = yield* WorkspaceFileSystem; | ||
| 164 | const cwd = yield* makeTempDir; | ||
| 165 | const path = yield* Path.Path; | ||
| 166 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 167 | |||
| 168 | const error = yield* workspaceFileSystem | ||
| 169 | .writeFile({ | ||
| 170 | cwd, | ||
| 171 | relativePath: "../escape.md", | ||
| 172 | contents: "# nope\n", | ||
| 173 | }) | ||
| 174 | .pipe(Effect.flip); | ||
| 175 | |||
| 176 | expect(error.message).toContain( | ||
| 177 | "Workspace file path must be relative to the project root: ../escape.md", | ||
| 178 | ); | ||
| 179 | |||
| 180 | const escapedPath = path.resolve(cwd, "..", "escape.md"); | ||
| 181 | const escapedStat = yield* fileSystem | ||
| 182 | .stat(escapedPath) | ||
| 183 | .pipe(Effect.orElseSucceed(() => null)); | ||
| 184 | expect(escapedStat).toBeNull(); | ||
| 185 | }), | ||
| 186 | ); | ||
| 187 | }); | ||
| 188 | }); | ||
apps/server/src/workspace/Layers/WorkspaceFileSystem.ts deleted-123| ... | @@ -1,123 +0,0 @@ | ||
| 1 | // @effect-diagnostics nodeBuiltinImport:off | ||
| 2 | import fsPromises from "node:fs/promises"; | ||
| 3 | |||
| 4 | import * as Effect from "effect/Effect"; | ||
| 5 | import * as FileSystem from "effect/FileSystem"; | ||
| 6 | import * as Layer from "effect/Layer"; | ||
| 7 | import * as Path from "effect/Path"; | ||
| 8 | |||
| 9 | import { | ||
| 10 | WorkspaceFileSystem, | ||
| 11 | WorkspaceFileSystemError, | ||
| 12 | type WorkspaceFileSystemShape, | ||
| 13 | } from "../Services/WorkspaceFileSystem.ts"; | ||
| 14 | import * as WorkspaceEntries from "../WorkspaceEntries.ts"; | ||
| 15 | import { WorkspacePaths } from "../Services/WorkspacePaths.ts"; | ||
| 16 | |||
| 17 | const PROJECT_READ_FILE_MAX_BYTES = 1024 * 1024; | ||
| 18 | |||
| 19 | export const makeWorkspaceFileSystem = Effect.gen(function* () { | ||
| 20 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 21 | const path = yield* Path.Path; | ||
| 22 | const workspacePaths = yield* WorkspacePaths; | ||
| 23 | const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; | ||
| 24 | |||
| 25 | const readFile: WorkspaceFileSystemShape["readFile"] = Effect.fn("WorkspaceFileSystem.readFile")( | ||
| 26 | function* (input) { | ||
| 27 | const target = yield* workspacePaths.resolveRelativePathWithinRoot({ | ||
| 28 | workspaceRoot: input.cwd, | ||
| 29 | relativePath: input.relativePath, | ||
| 30 | }); | ||
| 31 | |||
| 32 | const result = yield* Effect.tryPromise({ | ||
| 33 | try: async () => { | ||
| 34 | const [realWorkspaceRoot, realTargetPath] = await Promise.all([ | ||
| 35 | fsPromises.realpath(input.cwd), | ||
| 36 | fsPromises.realpath(target.absolutePath), | ||
| 37 | ]); | ||
| 38 | const relativeRealPath = path.relative(realWorkspaceRoot, realTargetPath); | ||
| 39 | if ( | ||
| 40 | relativeRealPath.startsWith(`..${path.sep}`) || | ||
| 41 | relativeRealPath === ".." || | ||
| 42 | path.isAbsolute(relativeRealPath) | ||
| 43 | ) { | ||
| 44 | throw new Error("Workspace file path resolves outside the project root."); | ||
| 45 | } | ||
| 46 | |||
| 47 | const handle = await fsPromises.open(realTargetPath, "r"); | ||
| 48 | try { | ||
| 49 | const stat = await handle.stat(); | ||
| 50 | if (!stat.isFile()) { | ||
| 51 | throw new Error("Workspace path is not a file."); | ||
| 52 | } | ||
| 53 | const bytesToRead = Math.min(stat.size, PROJECT_READ_FILE_MAX_BYTES); | ||
| 54 | const buffer = Buffer.alloc(bytesToRead); | ||
| 55 | const { bytesRead } = await handle.read(buffer, 0, bytesToRead, 0); | ||
| 56 | const fileBytes = buffer.subarray(0, bytesRead); | ||
| 57 | if (fileBytes.includes(0)) { | ||
| 58 | throw new Error("Binary files cannot be previewed as text."); | ||
| 59 | } | ||
| 60 | const contents = new TextDecoder("utf-8").decode(fileBytes); | ||
| 61 | return { | ||
| 62 | relativePath: target.relativePath, | ||
| 63 | contents, | ||
| 64 | byteLength: stat.size, | ||
| 65 | truncated: stat.size > PROJECT_READ_FILE_MAX_BYTES, | ||
| 66 | }; | ||
| 67 | } finally { | ||
| 68 | await handle.close(); | ||
| 69 | } | ||
| 70 | }, | ||
| 71 | catch: (cause) => | ||
| 72 | new WorkspaceFileSystemError({ | ||
| 73 | cwd: input.cwd, | ||
| 74 | relativePath: input.relativePath, | ||
| 75 | operation: "workspaceFileSystem.readFile", | ||
| 76 | detail: cause instanceof Error ? cause.message : String(cause), | ||
| 77 | cause, | ||
| 78 | }), | ||
| 79 | }); | ||
| 80 | |||
| 81 | return result; | ||
| 82 | }, | ||
| 83 | ); | ||
| 84 | |||
| 85 | const writeFile: WorkspaceFileSystemShape["writeFile"] = Effect.fn( | ||
| 86 | "WorkspaceFileSystem.writeFile", | ||
| 87 | )(function* (input) { | ||
| 88 | const target = yield* workspacePaths.resolveRelativePathWithinRoot({ | ||
| 89 | workspaceRoot: input.cwd, | ||
| 90 | relativePath: input.relativePath, | ||
| 91 | }); | ||
| 92 | |||
| 93 | yield* fileSystem.makeDirectory(path.dirname(target.absolutePath), { recursive: true }).pipe( | ||
| 94 | Effect.mapError( | ||
| 95 | (cause) => | ||
| 96 | new WorkspaceFileSystemError({ | ||
| 97 | cwd: input.cwd, | ||
| 98 | relativePath: input.relativePath, | ||
| 99 | operation: "workspaceFileSystem.makeDirectory", | ||
| 100 | detail: cause.message, | ||
| 101 | cause, | ||
| 102 | }), | ||
| 103 | ), | ||
| 104 | ); | ||
| 105 | yield* fileSystem.writeFileString(target.absolutePath, input.contents).pipe( | ||
| 106 | Effect.mapError( | ||
| 107 | (cause) => | ||
| 108 | new WorkspaceFileSystemError({ | ||
| 109 | cwd: input.cwd, | ||
| 110 | relativePath: input.relativePath, | ||
| 111 | operation: "workspaceFileSystem.writeFile", | ||
| 112 | detail: cause.message, | ||
| 113 | cause, | ||
| 114 | }), | ||
| 115 | ), | ||
| 116 | ); | ||
| 117 | yield* workspaceEntries.refresh(input.cwd); | ||
| 118 | return { relativePath: target.relativePath }; | ||
| 119 | }); | ||
| 120 | return { readFile, writeFile } satisfies WorkspaceFileSystemShape; | ||
| 121 | }); | ||
| 122 | |||
| 123 | export const WorkspaceFileSystemLive = Layer.effect(WorkspaceFileSystem, makeWorkspaceFileSystem); | ||
apps/server/src/workspace/Layers/WorkspacePaths.test.ts deleted-134| ... | @@ -1,134 +0,0 @@ | ||
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| 2 | import { it, describe, expect } from "@effect/vitest"; | ||
| 3 | import * as Effect from "effect/Effect"; | ||
| 4 | import * as FileSystem from "effect/FileSystem"; | ||
| 5 | import * as Layer from "effect/Layer"; | ||
| 6 | import * as Path from "effect/Path"; | ||
| 7 | |||
| 8 | import { WorkspacePaths } from "../Services/WorkspacePaths.ts"; | ||
| 9 | import { WorkspacePathsLive } from "./WorkspacePaths.ts"; | ||
| 10 | |||
| 11 | const TestLayer = Layer.empty.pipe( | ||
| 12 | Layer.provideMerge(WorkspacePathsLive), | ||
| 13 | Layer.provideMerge(NodeServices.layer), | ||
| 14 | ); | ||
| 15 | |||
| 16 | const makeTempDir = Effect.fn("makeTempDir")(function* () { | ||
| 17 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 18 | return yield* fileSystem.makeTempDirectoryScoped({ | ||
| 19 | prefix: "t3code-project-paths-", | ||
| 20 | }); | ||
| 21 | }); | ||
| 22 | |||
| 23 | const writeTextFile = Effect.fn("writeTextFile")(function* ( | ||
| 24 | cwd: string, | ||
| 25 | relativePath: string, | ||
| 26 | contents = "", | ||
| 27 | ) { | ||
| 28 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 29 | const path = yield* Path.Path; | ||
| 30 | const absolutePath = path.join(cwd, relativePath); | ||
| 31 | yield* fileSystem | ||
| 32 | .makeDirectory(path.dirname(absolutePath), { recursive: true }) | ||
| 33 | .pipe(Effect.orDie); | ||
| 34 | yield* fileSystem.writeFileString(absolutePath, contents).pipe(Effect.orDie); | ||
| 35 | }); | ||
| 36 | |||
| 37 | it.layer(TestLayer)("WorkspacePathsLive", (it) => { | ||
| 38 | describe("normalizeWorkspaceRoot", () => { | ||
| 39 | it.effect("resolves an existing directory", () => | ||
| 40 | Effect.gen(function* () { | ||
| 41 | const workspacePaths = yield* WorkspacePaths; | ||
| 42 | const cwd = yield* makeTempDir(); | ||
| 43 | |||
| 44 | const resolved = yield* workspacePaths.normalizeWorkspaceRoot(cwd); | ||
| 45 | |||
| 46 | expect(resolved).toBe(cwd); | ||
| 47 | }), | ||
| 48 | ); | ||
| 49 | |||
| 50 | it.effect("rejects missing directories", () => | ||
| 51 | Effect.gen(function* () { | ||
| 52 | const workspacePaths = yield* WorkspacePaths; | ||
| 53 | const cwd = yield* makeTempDir(); | ||
| 54 | const path = yield* Path.Path; | ||
| 55 | |||
| 56 | const error = yield* workspacePaths | ||
| 57 | .normalizeWorkspaceRoot(path.join(cwd, "missing")) | ||
| 58 | .pipe(Effect.flip); | ||
| 59 | |||
| 60 | expect(error.message).toContain("Workspace root does not exist:"); | ||
| 61 | }), | ||
| 62 | ); | ||
| 63 | |||
| 64 | it.effect("creates missing directories when createIfMissing is enabled", () => | ||
| 65 | Effect.gen(function* () { | ||
| 66 | const workspacePaths = yield* WorkspacePaths; | ||
| 67 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 68 | const cwd = yield* makeTempDir(); | ||
| 69 | const path = yield* Path.Path; | ||
| 70 | const missingPath = path.join(cwd, "nested", "new-project"); | ||
| 71 | |||
| 72 | const resolved = yield* workspacePaths.normalizeWorkspaceRoot(missingPath, { | ||
| 73 | createIfMissing: true, | ||
| 74 | }); | ||
| 75 | const stat = yield* fileSystem.stat(resolved); | ||
| 76 | |||
| 77 | expect(resolved).toBe(missingPath); | ||
| 78 | expect(stat.type).toBe("Directory"); | ||
| 79 | }), | ||
| 80 | ); | ||
| 81 | |||
| 82 | it.effect("rejects file paths", () => | ||
| 83 | Effect.gen(function* () { | ||
| 84 | const workspacePaths = yield* WorkspacePaths; | ||
| 85 | const cwd = yield* makeTempDir(); | ||
| 86 | const path = yield* Path.Path; | ||
| 87 | const filePath = path.join(cwd, "README.md"); | ||
| 88 | yield* writeTextFile(cwd, "README.md", "# hi\n"); | ||
| 89 | |||
| 90 | const error = yield* workspacePaths.normalizeWorkspaceRoot(filePath).pipe(Effect.flip); | ||
| 91 | |||
| 92 | expect(error.message).toContain("Workspace root is not a directory:"); | ||
| 93 | }), | ||
| 94 | ); | ||
| 95 | }); | ||
| 96 | |||
| 97 | describe("resolveRelativePathWithinRoot", () => { | ||
| 98 | it.effect("resolves relative paths inside the workspace root", () => | ||
| 99 | Effect.gen(function* () { | ||
| 100 | const workspacePaths = yield* WorkspacePaths; | ||
| 101 | const cwd = yield* makeTempDir(); | ||
| 102 | const path = yield* Path.Path; | ||
| 103 | |||
| 104 | const resolved = yield* workspacePaths.resolveRelativePathWithinRoot({ | ||
| 105 | workspaceRoot: cwd, | ||
| 106 | relativePath: "plans/effect-rpc.md", | ||
| 107 | }); | ||
| 108 | |||
| 109 | expect(resolved).toEqual({ | ||
| 110 | absolutePath: path.join(cwd, "plans/effect-rpc.md"), | ||
| 111 | relativePath: "plans/effect-rpc.md", | ||
| 112 | }); | ||
| 113 | }), | ||
| 114 | ); | ||
| 115 | |||
| 116 | it.effect("rejects paths that escape the workspace root", () => | ||
| 117 | Effect.gen(function* () { | ||
| 118 | const workspacePaths = yield* WorkspacePaths; | ||
| 119 | const cwd = yield* makeTempDir(); | ||
| 120 | |||
| 121 | const error = yield* workspacePaths | ||
| 122 | .resolveRelativePathWithinRoot({ | ||
| 123 | workspaceRoot: cwd, | ||
| 124 | relativePath: "../escape.md", | ||
| 125 | }) | ||
| 126 | .pipe(Effect.flip); | ||
| 127 | |||
| 128 | expect(error.message).toContain( | ||
| 129 | "Workspace file path must be relative to the project root: ../escape.md", | ||
| 130 | ); | ||
| 131 | }), | ||
| 132 | ); | ||
| 133 | }); | ||
| 134 | }); | ||
apps/server/src/workspace/Layers/WorkspacePaths.ts deleted-107| ... | @@ -1,107 +0,0 @@ | ||
| 1 | import * as NodeOS from "node:os"; | ||
| 2 | import * as Effect from "effect/Effect"; | ||
| 3 | import * as FileSystem from "effect/FileSystem"; | ||
| 4 | import * as Layer from "effect/Layer"; | ||
| 5 | import * as Path from "effect/Path"; | ||
| 6 | |||
| 7 | import { | ||
| 8 | WorkspacePaths, | ||
| 9 | WorkspacePathOutsideRootError, | ||
| 10 | WorkspaceRootCreateFailedError, | ||
| 11 | WorkspaceRootNotDirectoryError, | ||
| 12 | WorkspaceRootNotExistsError, | ||
| 13 | type WorkspacePathsShape, | ||
| 14 | } from "../Services/WorkspacePaths.ts"; | ||
| 15 | |||
| 16 | function toPosixRelativePath(input: string): string { | ||
| 17 | return input.replaceAll("\\", "/"); | ||
| 18 | } | ||
| 19 | |||
| 20 | function expandHomePath(input: string, path: Path.Path): string { | ||
| 21 | if (input === "~") { | ||
| 22 | return NodeOS.homedir(); | ||
| 23 | } | ||
| 24 | if (input.startsWith("~/") || input.startsWith("~\\")) { | ||
| 25 | return path.join(NodeOS.homedir(), input.slice(2)); | ||
| 26 | } | ||
| 27 | return input; | ||
| 28 | } | ||
| 29 | |||
| 30 | export const makeWorkspacePaths = Effect.gen(function* () { | ||
| 31 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 32 | const path = yield* Path.Path; | ||
| 33 | |||
| 34 | const normalizeWorkspaceRoot: WorkspacePathsShape["normalizeWorkspaceRoot"] = Effect.fn( | ||
| 35 | "WorkspacePaths.normalizeWorkspaceRoot", | ||
| 36 | )(function* (workspaceRoot, options) { | ||
| 37 | const normalizedWorkspaceRoot = path.resolve(expandHomePath(workspaceRoot.trim(), path)); | ||
| 38 | let workspaceStat = yield* fileSystem | ||
| 39 | .stat(normalizedWorkspaceRoot) | ||
| 40 | .pipe(Effect.orElseSucceed(() => null)); | ||
| 41 | if (!workspaceStat && options?.createIfMissing) { | ||
| 42 | yield* fileSystem.makeDirectory(normalizedWorkspaceRoot, { recursive: true }).pipe( | ||
| 43 | Effect.mapError( | ||
| 44 | () => | ||
| 45 | new WorkspaceRootCreateFailedError({ | ||
| 46 | workspaceRoot, | ||
| 47 | normalizedWorkspaceRoot, | ||
| 48 | }), | ||
| 49 | ), | ||
| 50 | ); | ||
| 51 | workspaceStat = yield* fileSystem | ||
| 52 | .stat(normalizedWorkspaceRoot) | ||
| 53 | .pipe(Effect.orElseSucceed(() => null)); | ||
| 54 | } | ||
| 55 | if (!workspaceStat) { | ||
| 56 | return yield* new WorkspaceRootNotExistsError({ | ||
| 57 | workspaceRoot, | ||
| 58 | normalizedWorkspaceRoot, | ||
| 59 | }); | ||
| 60 | } | ||
| 61 | if (workspaceStat.type !== "Directory") { | ||
| 62 | return yield* new WorkspaceRootNotDirectoryError({ | ||
| 63 | workspaceRoot, | ||
| 64 | normalizedWorkspaceRoot, | ||
| 65 | }); | ||
| 66 | } | ||
| 67 | return normalizedWorkspaceRoot; | ||
| 68 | }); | ||
| 69 | |||
| 70 | const resolveRelativePathWithinRoot: WorkspacePathsShape["resolveRelativePathWithinRoot"] = | ||
| 71 | Effect.fn("WorkspacePaths.resolveRelativePathWithinRoot")(function* (input) { | ||
| 72 | const normalizedInputPath = input.relativePath.trim(); | ||
| 73 | if (path.isAbsolute(normalizedInputPath)) { | ||
| 74 | return yield* new WorkspacePathOutsideRootError({ | ||
| 75 | workspaceRoot: input.workspaceRoot, | ||
| 76 | relativePath: input.relativePath, | ||
| 77 | }); | ||
| 78 | } | ||
| 79 | |||
| 80 | const absolutePath = path.resolve(input.workspaceRoot, normalizedInputPath); | ||
| 81 | const relativeToRoot = toPosixRelativePath(path.relative(input.workspaceRoot, absolutePath)); | ||
| 82 | if ( | ||
| 83 | relativeToRoot.length === 0 || | ||
| 84 | relativeToRoot === "." || | ||
| 85 | relativeToRoot.startsWith("../") || | ||
| 86 | relativeToRoot === ".." || | ||
| 87 | path.isAbsolute(relativeToRoot) | ||
| 88 | ) { | ||
| 89 | return yield* new WorkspacePathOutsideRootError({ | ||
| 90 | workspaceRoot: input.workspaceRoot, | ||
| 91 | relativePath: input.relativePath, | ||
| 92 | }); | ||
| 93 | } | ||
| 94 | |||
| 95 | return { | ||
| 96 | absolutePath, | ||
| 97 | relativePath: relativeToRoot, | ||
| 98 | }; | ||
| 99 | }); | ||
| 100 | |||
| 101 | return { | ||
| 102 | normalizeWorkspaceRoot, | ||
| 103 | resolveRelativePathWithinRoot, | ||
| 104 | } satisfies WorkspacePathsShape; | ||
| 105 | }); | ||
| 106 | |||
| 107 | export const WorkspacePathsLive = Layer.effect(WorkspacePaths, makeWorkspacePaths); | ||
apps/server/src/workspace/Services/WorkspaceFileSystem.ts deleted-70| ... | @@ -1,70 +0,0 @@ | ||
| 1 | /** | ||
| 2 | * WorkspaceFileSystem - Effect service contract for workspace file mutations. | ||
| 3 | * | ||
| 4 | * Owns workspace-root-relative file write operations and their associated | ||
| 5 | * safety checks and cache invalidation hooks. | ||
| 6 | * | ||
| 7 | * @module WorkspaceFileSystem | ||
| 8 | */ | ||
| 9 | import * as Schema from "effect/Schema"; | ||
| 10 | import * as Context from "effect/Context"; | ||
| 11 | import type * as Effect from "effect/Effect"; | ||
| 12 | |||
| 13 | import type { | ||
| 14 | ProjectReadFileInput, | ||
| 15 | ProjectReadFileResult, | ||
| 16 | ProjectWriteFileInput, | ||
| 17 | ProjectWriteFileResult, | ||
| 18 | } from "@t3tools/contracts"; | ||
| 19 | import { WorkspacePathOutsideRootError } from "./WorkspacePaths.ts"; | ||
| 20 | |||
| 21 | export class WorkspaceFileSystemError extends Schema.TaggedErrorClass<WorkspaceFileSystemError>()( | ||
| 22 | "WorkspaceFileSystemError", | ||
| 23 | { | ||
| 24 | cwd: Schema.String, | ||
| 25 | relativePath: Schema.optional(Schema.String), | ||
| 26 | operation: Schema.String, | ||
| 27 | detail: Schema.String, | ||
| 28 | cause: Schema.optional(Schema.Defect()), | ||
| 29 | }, | ||
| 30 | ) { | ||
| 31 | override get message(): string { | ||
| 32 | return this.detail; | ||
| 33 | } | ||
| 34 | } | ||
| 35 | |||
| 36 | /** | ||
| 37 | * WorkspaceFileSystemShape - Service API for workspace-relative file operations. | ||
| 38 | */ | ||
| 39 | export interface WorkspaceFileSystemShape { | ||
| 40 | /** | ||
| 41 | * Read a UTF-8 text file relative to the workspace root. | ||
| 42 | */ | ||
| 43 | readonly readFile: ( | ||
| 44 | input: ProjectReadFileInput, | ||
| 45 | ) => Effect.Effect< | ||
| 46 | ProjectReadFileResult, | ||
| 47 | WorkspaceFileSystemError | WorkspacePathOutsideRootError | ||
| 48 | >; | ||
| 49 | |||
| 50 | /** | ||
| 51 | * Write a file relative to the workspace root. | ||
| 52 | * | ||
| 53 | * Creates parent directories as needed and rejects paths that escape the | ||
| 54 | * workspace root. | ||
| 55 | */ | ||
| 56 | readonly writeFile: ( | ||
| 57 | input: ProjectWriteFileInput, | ||
| 58 | ) => Effect.Effect< | ||
| 59 | ProjectWriteFileResult, | ||
| 60 | WorkspaceFileSystemError | WorkspacePathOutsideRootError | ||
| 61 | >; | ||
| 62 | } | ||
| 63 | |||
| 64 | /** | ||
| 65 | * WorkspaceFileSystem - Service tag for workspace file operations. | ||
| 66 | */ | ||
| 67 | export class WorkspaceFileSystem extends Context.Service< | ||
| 68 | WorkspaceFileSystem, | ||
| 69 | WorkspaceFileSystemShape | ||
| 70 | >()("t3/workspace/Services/WorkspaceFileSystem") {} | ||
apps/server/src/workspace/Services/WorkspacePaths.ts deleted-103| ... | @@ -1,103 +0,0 @@ | ||
| 1 | /** | ||
| 2 | * WorkspacePaths - Effect service contract for workspace path handling. | ||
| 3 | * | ||
| 4 | * Owns normalization and validation of workspace roots plus safe resolution of | ||
| 5 | * workspace-root-relative paths. | ||
| 6 | * | ||
| 7 | * @module WorkspacePaths | ||
| 8 | */ | ||
| 9 | import * as Schema from "effect/Schema"; | ||
| 10 | import * as Context from "effect/Context"; | ||
| 11 | import type * as Effect from "effect/Effect"; | ||
| 12 | |||
| 13 | export class WorkspaceRootNotExistsError extends Schema.TaggedErrorClass<WorkspaceRootNotExistsError>()( | ||
| 14 | "WorkspaceRootNotExistsError", | ||
| 15 | { | ||
| 16 | workspaceRoot: Schema.String, | ||
| 17 | normalizedWorkspaceRoot: Schema.String, | ||
| 18 | }, | ||
| 19 | ) { | ||
| 20 | override get message(): string { | ||
| 21 | return `Workspace root does not exist: ${this.normalizedWorkspaceRoot}`; | ||
| 22 | } | ||
| 23 | } | ||
| 24 | |||
| 25 | export class WorkspaceRootCreateFailedError extends Schema.TaggedErrorClass<WorkspaceRootCreateFailedError>()( | ||
| 26 | "WorkspaceRootCreateFailedError", | ||
| 27 | { | ||
| 28 | workspaceRoot: Schema.String, | ||
| 29 | normalizedWorkspaceRoot: Schema.String, | ||
| 30 | }, | ||
| 31 | ) { | ||
| 32 | override get message(): string { | ||
| 33 | return `Failed to create workspace root: ${this.normalizedWorkspaceRoot}`; | ||
| 34 | } | ||
| 35 | } | ||
| 36 | |||
| 37 | export class WorkspaceRootNotDirectoryError extends Schema.TaggedErrorClass<WorkspaceRootNotDirectoryError>()( | ||
| 38 | "WorkspaceRootNotDirectoryError", | ||
| 39 | { | ||
| 40 | workspaceRoot: Schema.String, | ||
| 41 | normalizedWorkspaceRoot: Schema.String, | ||
| 42 | }, | ||
| 43 | ) { | ||
| 44 | override get message(): string { | ||
| 45 | return `Workspace root is not a directory: ${this.normalizedWorkspaceRoot}`; | ||
| 46 | } | ||
| 47 | } | ||
| 48 | |||
| 49 | export class WorkspacePathOutsideRootError extends Schema.TaggedErrorClass<WorkspacePathOutsideRootError>()( | ||
| 50 | "WorkspacePathOutsideRootError", | ||
| 51 | { | ||
| 52 | workspaceRoot: Schema.String, | ||
| 53 | relativePath: Schema.String, | ||
| 54 | }, | ||
| 55 | ) { | ||
| 56 | override get message(): string { | ||
| 57 | return `Workspace file path must be relative to the project root: ${this.relativePath}`; | ||
| 58 | } | ||
| 59 | } | ||
| 60 | |||
| 61 | export const WorkspacePathsError = Schema.Union([ | ||
| 62 | WorkspaceRootNotExistsError, | ||
| 63 | WorkspaceRootCreateFailedError, | ||
| 64 | WorkspaceRootNotDirectoryError, | ||
| 65 | WorkspacePathOutsideRootError, | ||
| 66 | ]); | ||
| 67 | export type WorkspacePathsError = typeof WorkspacePathsError.Type; | ||
| 68 | |||
| 69 | /** | ||
| 70 | * WorkspacePathsShape - Service API for workspace path normalization and guards. | ||
| 71 | */ | ||
| 72 | export interface WorkspacePathsShape { | ||
| 73 | /** | ||
| 74 | * Normalize a user-provided workspace root and verify it exists as a directory. | ||
| 75 | */ | ||
| 76 | readonly normalizeWorkspaceRoot: ( | ||
| 77 | workspaceRoot: string, | ||
| 78 | options?: { readonly createIfMissing?: boolean }, | ||
| 79 | ) => Effect.Effect< | ||
| 80 | string, | ||
| 81 | WorkspaceRootNotExistsError | WorkspaceRootCreateFailedError | WorkspaceRootNotDirectoryError | ||
| 82 | >; | ||
| 83 | |||
| 84 | /** | ||
| 85 | * Resolve a relative path within a validated workspace root. | ||
| 86 | * | ||
| 87 | * Rejects absolute paths and traversal attempts outside the workspace root. | ||
| 88 | */ | ||
| 89 | readonly resolveRelativePathWithinRoot: (input: { | ||
| 90 | workspaceRoot: string; | ||
| 91 | relativePath: string; | ||
| 92 | }) => Effect.Effect< | ||
| 93 | { absolutePath: string; relativePath: string }, | ||
| 94 | WorkspacePathOutsideRootError | ||
| 95 | >; | ||
| 96 | } | ||
| 97 | |||
| 98 | /** | ||
| 99 | * WorkspacePaths - Service tag for workspace path normalization and resolution. | ||
| 100 | */ | ||
| 101 | export class WorkspacePaths extends Context.Service<WorkspacePaths, WorkspacePathsShape>()( | ||
| 102 | "t3/workspace/Services/WorkspacePaths", | ||
| 103 | ) {} | ||
apps/server/src/workspace/WorkspaceEntries.test.ts+9-6| ... | @@ -9,18 +9,18 @@ import * as Layer from "effect/Layer"; | ... | @@ -9,18 +9,18 @@ import * as Layer from "effect/Layer"; |
| 9 | import * as Path from "effect/Path"; | 9 | import * as Path from "effect/Path"; |
| 10 | import * as PlatformError from "effect/PlatformError"; | 10 | import * as PlatformError from "effect/PlatformError"; |
| 11 | 11 | ||
| 12 | import { ServerConfig } from "../config.ts"; | 12 | import * as ServerConfig from "../config.ts"; |
| 13 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; | 13 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; |
| 14 | import * as VcsProcess from "../vcs/VcsProcess.ts"; | 14 | import * as VcsProcess from "../vcs/VcsProcess.ts"; |
| 15 | import * as WorkspaceEntries from "./WorkspaceEntries.ts"; | 15 | import * as WorkspaceEntries from "./WorkspaceEntries.ts"; |
| 16 | import { WorkspacePathsLive } from "./Layers/WorkspacePaths.ts"; | 16 | import * as WorkspacePaths from "./WorkspacePaths.ts"; |
| 17 | 17 | ||
| 18 | const TestLayer = Layer.empty.pipe( | 18 | const TestLayer = Layer.empty.pipe( |
| 19 | Layer.provideMerge(WorkspaceEntries.layer.pipe(Layer.provide(WorkspacePathsLive))), | 19 | Layer.provideMerge(WorkspaceEntries.layer.pipe(Layer.provide(WorkspacePaths.layer))), |
| 20 | Layer.provideMerge(WorkspacePathsLive), | 20 | Layer.provideMerge(WorkspacePaths.layer), |
| 21 | Layer.provideMerge(VcsProcess.layer), | 21 | Layer.provideMerge(VcsProcess.layer), |
| 22 | Layer.provide( | 22 | Layer.provide( |
| 23 | ServerConfig.layerTest(process.cwd(), { | 23 | ServerConfig.ServerConfig.layerTest(process.cwd(), { |
| 24 | prefix: "t3-workspace-entries-test-", | 24 | prefix: "t3-workspace-entries-test-", |
| 25 | }), | 25 | }), |
| 26 | ), | 26 | ), |
| ... | @@ -363,7 +363,10 @@ it.layer(TestLayer, { excludeTestServices: true })("WorkspaceEntries", (it) => { | ... | @@ -363,7 +363,10 @@ it.layer(TestLayer, { excludeTestServices: true })("WorkspaceEntries", (it) => { |
| 363 | }) | 363 | }) |
| 364 | .pipe(Effect.flip); | 364 | .pipe(Effect.flip); |
| 365 | 365 | ||
| 366 | expect(error.detail).toBe("Relative filesystem browse paths require a current project."); | 366 | expect(error._tag).toBe("WorkspaceEntriesCurrentProjectRequiredError"); |
| 367 | expect(error.message).toBe( | ||
| 368 | "A current project is required to browse relative workspace path './src'.", | ||
| 369 | ); | ||
| 367 | }), | 370 | }), |
| 368 | ); | 371 | ); |
| 369 | 372 |
apps/server/src/workspace/WorkspaceEntries.ts+84-51| ... | @@ -1,6 +1,6 @@ | ... | @@ -1,6 +1,6 @@ |
| 1 | // @effect-diagnostics nodeBuiltinImport:off | 1 | // @effect-diagnostics nodeBuiltinImport:off |
| 2 | import * as NodeFSP from "node:fs/promises"; | 2 | import { readdir } from "node:fs/promises"; |
| 3 | import * as NodeOS from "node:os"; | 3 | import { homedir } from "node:os"; |
| 4 | 4 | ||
| 5 | import * as Context from "effect/Context"; | 5 | import * as Context from "effect/Context"; |
| 6 | import * as Effect from "effect/Effect"; | 6 | import * as Effect from "effect/Effect"; |
| ... | @@ -20,29 +20,72 @@ import type { | ... | @@ -20,29 +20,72 @@ import type { |
| 20 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; | 20 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; |
| 21 | import { isExplicitRelativePath, isWindowsAbsolutePath } from "@t3tools/shared/path"; | 21 | import { isExplicitRelativePath, isWindowsAbsolutePath } from "@t3tools/shared/path"; |
| 22 | 22 | ||
| 23 | import * as WorkspacePaths from "./Services/WorkspacePaths.ts"; | 23 | import * as WorkspacePaths from "./WorkspacePaths.ts"; |
| 24 | import * as WorkspaceSearchIndex from "./WorkspaceSearchIndex.ts"; | 24 | import * as WorkspaceSearchIndex from "./WorkspaceSearchIndex.ts"; |
| 25 | 25 | ||
| 26 | export class WorkspaceEntriesError extends Schema.TaggedErrorClass<WorkspaceEntriesError>()( | 26 | export class WorkspaceEntriesError extends Schema.TaggedErrorClass<WorkspaceEntriesError>()( |
| 27 | "WorkspaceEntriesError", | 27 | "WorkspaceEntriesError", |
| 28 | { | 28 | { |
| 29 | cwd: Schema.String, | 29 | cwd: Schema.String, |
| 30 | operation: Schema.String, | 30 | operation: Schema.Literals([ |
| 31 | detail: Schema.String, | 31 | "workspaceEntries.normalizeWorkspaceRoot", |
| 32 | cause: Schema.optional(Schema.Defect()), | 32 | "workspaceEntries.search", |
| 33 | "workspaceEntries.list", | ||
| 34 | ]), | ||
| 35 | cause: Schema.Defect(), | ||
| 33 | }, | 36 | }, |
| 34 | ) {} | 37 | ) { |
| 38 | override get message(): string { | ||
| 39 | return `Workspace entries operation '${this.operation}' failed for '${this.cwd}'.`; | ||
| 40 | } | ||
| 41 | } | ||
| 35 | 42 | ||
| 36 | export class WorkspaceEntriesBrowseError extends Schema.TaggedErrorClass<WorkspaceEntriesBrowseError>()( | 43 | export class WorkspaceEntriesWindowsPathUnsupportedError extends Schema.TaggedErrorClass<WorkspaceEntriesWindowsPathUnsupportedError>()( |
| 37 | "WorkspaceEntriesBrowseError", | 44 | "WorkspaceEntriesWindowsPathUnsupportedError", |
| 38 | { | 45 | { |
| 39 | cwd: Schema.optional(Schema.String), | 46 | cwd: Schema.optional(Schema.String), |
| 40 | partialPath: Schema.String, | 47 | partialPath: Schema.String, |
| 41 | operation: Schema.String, | 48 | platform: Schema.String, |
| 42 | detail: Schema.String, | ||
| 43 | cause: Schema.optional(Schema.Defect()), | ||
| 44 | }, | 49 | }, |
| 45 | ) {} | 50 | ) { |
| 51 | override get message(): string { | ||
| 52 | const cwd = this.cwd ? ` from '${this.cwd}'` : ""; | ||
| 53 | return `Windows-style workspace path '${this.partialPath}' is not supported on '${this.platform}'${cwd}.`; | ||
| 54 | } | ||
| 55 | } | ||
| 56 | |||
| 57 | export class WorkspaceEntriesCurrentProjectRequiredError extends Schema.TaggedErrorClass<WorkspaceEntriesCurrentProjectRequiredError>()( | ||
| 58 | "WorkspaceEntriesCurrentProjectRequiredError", | ||
| 59 | { | ||
| 60 | partialPath: Schema.String, | ||
| 61 | }, | ||
| 62 | ) { | ||
| 63 | override get message(): string { | ||
| 64 | return `A current project is required to browse relative workspace path '${this.partialPath}'.`; | ||
| 65 | } | ||
| 66 | } | ||
| 67 | |||
| 68 | export class WorkspaceEntriesReadDirectoryError extends Schema.TaggedErrorClass<WorkspaceEntriesReadDirectoryError>()( | ||
| 69 | "WorkspaceEntriesReadDirectoryError", | ||
| 70 | { | ||
| 71 | cwd: Schema.optional(Schema.String), | ||
| 72 | partialPath: Schema.String, | ||
| 73 | parentPath: Schema.String, | ||
| 74 | cause: Schema.Defect(), | ||
| 75 | }, | ||
| 76 | ) { | ||
| 77 | override get message(): string { | ||
| 78 | const cwd = this.cwd ? ` from '${this.cwd}'` : ""; | ||
| 79 | return `Failed to read workspace directory '${this.parentPath}' while browsing '${this.partialPath}'${cwd}.`; | ||
| 80 | } | ||
| 81 | } | ||
| 82 | |||
| 83 | export const WorkspaceEntriesBrowseError = Schema.Union([ | ||
| 84 | WorkspaceEntriesWindowsPathUnsupportedError, | ||
| 85 | WorkspaceEntriesCurrentProjectRequiredError, | ||
| 86 | WorkspaceEntriesReadDirectoryError, | ||
| 87 | ]); | ||
| 88 | export type WorkspaceEntriesBrowseError = typeof WorkspaceEntriesBrowseError.Type; | ||
| 46 | 89 | ||
| 47 | export class WorkspaceEntries extends Context.Service< | 90 | export class WorkspaceEntries extends Context.Service< |
| 48 | WorkspaceEntries, | 91 | WorkspaceEntries, |
| ... | @@ -62,46 +105,40 @@ export class WorkspaceEntries extends Context.Service< | ... | @@ -62,46 +105,40 @@ export class WorkspaceEntries extends Context.Service< |
| 62 | 105 | ||
| 63 | function expandHomePath(input: string, path: Path.Path): string { | 106 | function expandHomePath(input: string, path: Path.Path): string { |
| 64 | if (input === "~") { | 107 | if (input === "~") { |
| 65 | return NodeOS.homedir(); | 108 | return homedir(); |
| 66 | } | 109 | } |
| 67 | if (input.startsWith("~/") || input.startsWith("~\\")) { | 110 | if (input.startsWith("~/") || input.startsWith("~\\")) { |
| 68 | return path.join(NodeOS.homedir(), input.slice(2)); | 111 | return path.join(homedir(), input.slice(2)); |
| 69 | } | 112 | } |
| 70 | return input; | 113 | return input; |
| 71 | } | 114 | } |
| 72 | 115 | ||
| 73 | const resolveBrowseTarget = ( | 116 | const resolveBrowseTarget = Effect.fn("WorkspaceEntries.resolveBrowseTarget")(function* ( |
| 74 | input: FilesystemBrowseInput, | 117 | input: FilesystemBrowseInput, |
| 75 | path: Path.Path, | 118 | path: Path.Path, |
| 76 | ): Effect.Effect<string, WorkspaceEntriesBrowseError> => | 119 | ): Effect.fn.Return<string, WorkspaceEntriesBrowseError> { |
| 77 | Effect.gen(function* () { | 120 | const platform = yield* HostProcessPlatform; |
| 78 | const platform = yield* HostProcessPlatform; | 121 | if (platform !== "win32" && isWindowsAbsolutePath(input.partialPath)) { |
| 79 | if (platform !== "win32" && isWindowsAbsolutePath(input.partialPath)) { | 122 | return yield* new WorkspaceEntriesWindowsPathUnsupportedError({ |
| 80 | return yield* new WorkspaceEntriesBrowseError({ | 123 | cwd: input.cwd, |
| 81 | cwd: input.cwd, | 124 | partialPath: input.partialPath, |
| 82 | partialPath: input.partialPath, | 125 | platform, |
| 83 | operation: "workspaceEntries.resolveBrowseTarget", | 126 | }); |
| 84 | detail: "Windows-style paths are only supported on Windows.", | 127 | } |
| 85 | }); | 128 | |
| 86 | } | 129 | if (!isExplicitRelativePath(input.partialPath)) { |
| 87 | 130 | return path.resolve(expandHomePath(input.partialPath, path)); | |
| 88 | if (!isExplicitRelativePath(input.partialPath)) { | 131 | } |
| 89 | return path.resolve(expandHomePath(input.partialPath, path)); | 132 | |
| 90 | } | 133 | if (!input.cwd) { |
| 91 | 134 | return yield* new WorkspaceEntriesCurrentProjectRequiredError({ | |
| 92 | if (!input.cwd) { | 135 | partialPath: input.partialPath, |
| 93 | return yield* new WorkspaceEntriesBrowseError({ | 136 | }); |
| 94 | cwd: input.cwd, | 137 | } |
| 95 | partialPath: input.partialPath, | 138 | return path.resolve(expandHomePath(input.cwd, path), input.partialPath); |
| 96 | operation: "workspaceEntries.resolveBrowseTarget", | 139 | }); |
| 97 | detail: "Relative filesystem browse paths require a current project.", | ||
| 98 | }); | ||
| 99 | } | ||
| 100 | |||
| 101 | return path.resolve(expandHomePath(input.cwd, path), input.partialPath); | ||
| 102 | }); | ||
| 103 | 140 | ||
| 104 | const make = Effect.gen(function* () { | 141 | export const make = Effect.gen(function* () { |
| 105 | const path = yield* Path.Path; | 142 | const path = yield* Path.Path; |
| 106 | const workspacePaths = yield* WorkspacePaths.WorkspacePaths; | 143 | const workspacePaths = yield* WorkspacePaths.WorkspacePaths; |
| 107 | const workspaceSearchIndexes = yield* WorkspaceSearchIndex.WorkspaceSearchIndexMap; | 144 | const workspaceSearchIndexes = yield* WorkspaceSearchIndex.WorkspaceSearchIndexMap; |
| ... | @@ -115,7 +152,6 @@ const make = Effect.gen(function* () { | ... | @@ -115,7 +152,6 @@ const make = Effect.gen(function* () { |
| 115 | new WorkspaceEntriesError({ | 152 | new WorkspaceEntriesError({ |
| 116 | cwd, | 153 | cwd, |
| 117 | operation: "workspaceEntries.normalizeWorkspaceRoot", | 154 | operation: "workspaceEntries.normalizeWorkspaceRoot", |
| 118 | detail: cause.message, | ||
| 119 | cause, | 155 | cause, |
| 120 | }), | 156 | }), |
| 121 | ), | 157 | ), |
| ... | @@ -156,13 +192,12 @@ const make = Effect.gen(function* () { | ... | @@ -156,13 +192,12 @@ const make = Effect.gen(function* () { |
| 156 | const prefix = endsWithSeparator ? "" : path.basename(resolvedInputPath); | 192 | const prefix = endsWithSeparator ? "" : path.basename(resolvedInputPath); |
| 157 | 193 | ||
| 158 | const dirents = yield* Effect.tryPromise({ | 194 | const dirents = yield* Effect.tryPromise({ |
| 159 | try: () => NodeFSP.readdir(parentPath, { withFileTypes: true }), | 195 | try: () => readdir(parentPath, { withFileTypes: true }), |
| 160 | catch: (cause) => | 196 | catch: (cause) => |
| 161 | new WorkspaceEntriesBrowseError({ | 197 | new WorkspaceEntriesReadDirectoryError({ |
| 162 | cwd: input.cwd, | 198 | cwd: input.cwd, |
| 163 | partialPath: input.partialPath, | 199 | partialPath: input.partialPath, |
| 164 | operation: "workspaceEntries.browse.readDirectory", | 200 | parentPath, |
| 165 | detail: `Unable to browse '${parentPath}': ${cause instanceof Error ? cause.message : String(cause)}`, | ||
| 166 | cause, | 201 | cause, |
| 167 | }), | 202 | }), |
| 168 | }).pipe( | 203 | }).pipe( |
| ... | @@ -215,7 +250,6 @@ const make = Effect.gen(function* () { | ... | @@ -215,7 +250,6 @@ const make = Effect.gen(function* () { |
| 215 | new WorkspaceEntriesError({ | 250 | new WorkspaceEntriesError({ |
| 216 | cwd: input.cwd, | 251 | cwd: input.cwd, |
| 217 | operation: "workspaceEntries.search", | 252 | operation: "workspaceEntries.search", |
| 218 | detail: cause.message, | ||
| 219 | cause, | 253 | cause, |
| 220 | }), | 254 | }), |
| 221 | ), | 255 | ), |
| ... | @@ -236,7 +270,6 @@ const make = Effect.gen(function* () { | ... | @@ -236,7 +270,6 @@ const make = Effect.gen(function* () { |
| 236 | new WorkspaceEntriesError({ | 270 | new WorkspaceEntriesError({ |
| 237 | cwd: input.cwd, | 271 | cwd: input.cwd, |
| 238 | operation: "workspaceEntries.list", | 272 | operation: "workspaceEntries.list", |
| 239 | detail: cause.message, | ||
| 240 | cause, | 273 | cause, |
| 241 | }), | 274 | }), |
| 242 | ), | 275 | ), |
apps/server/src/workspace/WorkspaceFileSystem.test.ts created+193| ... | @@ -0,0 +1,193 @@ | ||
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| 2 | import { it, describe, expect } from "@effect/vitest"; | ||
| 3 | import * as Effect from "effect/Effect"; | ||
| 4 | import * as FileSystem from "effect/FileSystem"; | ||
| 5 | import * as Layer from "effect/Layer"; | ||
| 6 | import * as Path from "effect/Path"; | ||
| 7 | |||
| 8 | import * as ServerConfig from "../config.ts"; | ||
| 9 | import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; | ||
| 10 | import * as VcsProcess from "../vcs/VcsProcess.ts"; | ||
| 11 | import * as WorkspaceEntries from "./WorkspaceEntries.ts"; | ||
| 12 | import * as WorkspaceFileSystem from "./WorkspaceFileSystem.ts"; | ||
| 13 | import * as WorkspacePaths from "./WorkspacePaths.ts"; | ||
| 14 | |||
| 15 | const ProjectLayer = WorkspaceFileSystem.layer.pipe( | ||
| 16 | Layer.provide(WorkspacePaths.layer), | ||
| 17 | Layer.provide(WorkspaceEntries.layer.pipe(Layer.provide(WorkspacePaths.layer))), | ||
| 18 | ); | ||
| 19 | |||
| 20 | const TestLayer = Layer.empty.pipe( | ||
| 21 | Layer.provideMerge(ProjectLayer), | ||
| 22 | Layer.provideMerge(WorkspaceEntries.layer.pipe(Layer.provide(WorkspacePaths.layer))), | ||
| 23 | Layer.provideMerge(WorkspacePaths.layer), | ||
| 24 | Layer.provideMerge(VcsDriverRegistry.layer.pipe(Layer.provide(VcsProcess.layer))), | ||
| 25 | Layer.provide( | ||
| 26 | ServerConfig.ServerConfig.layerTest(process.cwd(), { | ||
| 27 | prefix: "t3-workspace-files-test-", | ||
| 28 | }), | ||
| 29 | ), | ||
| 30 | Layer.provideMerge(NodeServices.layer), | ||
| 31 | ); | ||
| 32 | |||
| 33 | const makeTempDir = Effect.gen(function* () { | ||
| 34 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 35 | return yield* fileSystem.makeTempDirectoryScoped({ | ||
| 36 | prefix: "t3code-workspace-files-", | ||
| 37 | }); | ||
| 38 | }); | ||
| 39 | |||
| 40 | const writeTextFile = Effect.fn("writeTextFile")(function* ( | ||
| 41 | cwd: string, | ||
| 42 | relativePath: string, | ||
| 43 | contents = "", | ||
| 44 | ) { | ||
| 45 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 46 | const path = yield* Path.Path; | ||
| 47 | const absolutePath = path.join(cwd, relativePath); | ||
| 48 | yield* fileSystem | ||
| 49 | .makeDirectory(path.dirname(absolutePath), { recursive: true }) | ||
| 50 | .pipe(Effect.orDie); | ||
| 51 | yield* fileSystem.writeFileString(absolutePath, contents).pipe(Effect.orDie); | ||
| 52 | }); | ||
| 53 | |||
| 54 | it.layer(TestLayer, { excludeTestServices: true })("WorkspaceFileSystemLive", (it) => { | ||
| 55 | describe("readFile", () => { | ||
| 56 | it.effect("reads UTF-8 files relative to the workspace root", () => | ||
| 57 | Effect.gen(function* () { | ||
| 58 | const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem; | ||
| 59 | const cwd = yield* makeTempDir; | ||
| 60 | yield* writeTextFile(cwd, "src/index.ts", "export const answer = 42;\n"); | ||
| 61 | |||
| 62 | const result = yield* workspaceFileSystem.readFile({ | ||
| 63 | cwd, | ||
| 64 | relativePath: "src/index.ts", | ||
| 65 | }); | ||
| 66 | |||
| 67 | expect(result).toEqual({ | ||
| 68 | relativePath: "src/index.ts", | ||
| 69 | contents: "export const answer = 42;\n", | ||
| 70 | byteLength: 26, | ||
| 71 | truncated: false, | ||
| 72 | }); | ||
| 73 | }), | ||
| 74 | ); | ||
| 75 | |||
| 76 | it.effect("rejects reads outside the workspace root", () => | ||
| 77 | Effect.gen(function* () { | ||
| 78 | const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem; | ||
| 79 | const cwd = yield* makeTempDir; | ||
| 80 | |||
| 81 | const error = yield* workspaceFileSystem | ||
| 82 | .readFile({ cwd, relativePath: "../escape.md" }) | ||
| 83 | .pipe(Effect.flip); | ||
| 84 | |||
| 85 | expect(error.message).toContain( | ||
| 86 | "Workspace file path must be relative to the project root: ../escape.md", | ||
| 87 | ); | ||
| 88 | }), | ||
| 89 | ); | ||
| 90 | |||
| 91 | it.effect("rejects symlinks that resolve outside the workspace root", () => | ||
| 92 | Effect.gen(function* () { | ||
| 93 | const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem; | ||
| 94 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 95 | const path = yield* Path.Path; | ||
| 96 | const cwd = yield* makeTempDir; | ||
| 97 | const outsideDir = yield* makeTempDir; | ||
| 98 | yield* writeTextFile(outsideDir, "secret.txt", "outside\n"); | ||
| 99 | yield* fileSystem.symlink( | ||
| 100 | path.join(outsideDir, "secret.txt"), | ||
| 101 | path.join(cwd, "linked-secret.txt"), | ||
| 102 | ); | ||
| 103 | |||
| 104 | const error = yield* workspaceFileSystem | ||
| 105 | .readFile({ cwd, relativePath: "linked-secret.txt" }) | ||
| 106 | .pipe(Effect.flip); | ||
| 107 | |||
| 108 | expect(error.message).toBe( | ||
| 109 | `Workspace file operation 'workspaceFileSystem.readFile' failed for 'linked-secret.txt' in '${cwd}'.`, | ||
| 110 | ); | ||
| 111 | expect(error.cause).toBeInstanceOf(Error); | ||
| 112 | expect((error.cause as Error).message).toBe( | ||
| 113 | "Workspace file path resolves outside the project root.", | ||
| 114 | ); | ||
| 115 | }), | ||
| 116 | ); | ||
| 117 | }); | ||
| 118 | |||
| 119 | describe("writeFile", () => { | ||
| 120 | it.effect("writes files relative to the workspace root", () => | ||
| 121 | Effect.gen(function* () { | ||
| 122 | const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem; | ||
| 123 | const cwd = yield* makeTempDir; | ||
| 124 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 125 | const path = yield* Path.Path; | ||
| 126 | const result = yield* workspaceFileSystem.writeFile({ | ||
| 127 | cwd, | ||
| 128 | relativePath: "plans/effect-rpc.md", | ||
| 129 | contents: "# Plan\n", | ||
| 130 | }); | ||
| 131 | const saved = yield* fileSystem | ||
| 132 | .readFileString(path.join(cwd, "plans/effect-rpc.md")) | ||
| 133 | .pipe(Effect.orDie); | ||
| 134 | |||
| 135 | expect(result).toEqual({ relativePath: "plans/effect-rpc.md" }); | ||
| 136 | expect(saved).toBe("# Plan\n"); | ||
| 137 | }), | ||
| 138 | ); | ||
| 139 | |||
| 140 | it.effect("invalidates workspace entry search cache after writes", () => | ||
| 141 | Effect.gen(function* () { | ||
| 142 | const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; | ||
| 143 | const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem; | ||
| 144 | const cwd = yield* makeTempDir; | ||
| 145 | yield* writeTextFile(cwd, "src/existing.ts", "export {};\n"); | ||
| 146 | |||
| 147 | const beforeWrite = yield* workspaceEntries.list({ cwd }); | ||
| 148 | expect(beforeWrite.entries.some((entry) => entry.path === "plans/effect-rpc.md")).toBe( | ||
| 149 | false, | ||
| 150 | ); | ||
| 151 | |||
| 152 | yield* workspaceFileSystem.writeFile({ | ||
| 153 | cwd, | ||
| 154 | relativePath: "plans/effect-rpc.md", | ||
| 155 | contents: "# Plan\n", | ||
| 156 | }); | ||
| 157 | |||
| 158 | const afterWrite = yield* workspaceEntries.list({ cwd }); | ||
| 159 | expect(afterWrite.entries).toEqual( | ||
| 160 | expect.arrayContaining([expect.objectContaining({ path: "plans/effect-rpc.md" })]), | ||
| 161 | ); | ||
| 162 | expect(afterWrite.truncated).toBe(false); | ||
| 163 | }), | ||
| 164 | ); | ||
| 165 | |||
| 166 | it.effect("rejects writes outside the workspace root", () => | ||
| 167 | Effect.gen(function* () { | ||
| 168 | const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem; | ||
| 169 | const cwd = yield* makeTempDir; | ||
| 170 | const path = yield* Path.Path; | ||
| 171 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 172 | |||
| 173 | const error = yield* workspaceFileSystem | ||
| 174 | .writeFile({ | ||
| 175 | cwd, | ||
| 176 | relativePath: "../escape.md", | ||
| 177 | contents: "# nope\n", | ||
| 178 | }) | ||
| 179 | .pipe(Effect.flip); | ||
| 180 | |||
| 181 | expect(error.message).toContain( | ||
| 182 | "Workspace file path must be relative to the project root: ../escape.md", | ||
| 183 | ); | ||
| 184 | |||
| 185 | const escapedPath = path.resolve(cwd, "..", "escape.md"); | ||
| 186 | const escapedStat = yield* fileSystem | ||
| 187 | .stat(escapedPath) | ||
| 188 | .pipe(Effect.orElseSucceed(() => null)); | ||
| 189 | expect(escapedStat).toBeNull(); | ||
| 190 | }), | ||
| 191 | ); | ||
| 192 | }); | ||
| 193 | }); | ||
apps/server/src/workspace/WorkspaceFileSystem.ts created+175| ... | @@ -0,0 +1,175 @@ | ||
| 1 | // @effect-diagnostics nodeBuiltinImport:off | ||
| 2 | /** | ||
| 3 | * WorkspaceFileSystem - Effect service contract for workspace file mutations. | ||
| 4 | * | ||
| 5 | * Owns workspace-root-relative file read/write operations and their associated | ||
| 6 | * safety checks and cache invalidation hooks. | ||
| 7 | * | ||
| 8 | * @module WorkspaceFileSystem | ||
| 9 | */ | ||
| 10 | import { open, realpath } from "node:fs/promises"; | ||
| 11 | |||
| 12 | import type { | ||
| 13 | ProjectReadFileInput, | ||
| 14 | ProjectReadFileResult, | ||
| 15 | ProjectWriteFileInput, | ||
| 16 | ProjectWriteFileResult, | ||
| 17 | } from "@t3tools/contracts"; | ||
| 18 | import * as Context from "effect/Context"; | ||
| 19 | import * as Effect from "effect/Effect"; | ||
| 20 | import * as FileSystem from "effect/FileSystem"; | ||
| 21 | import * as Layer from "effect/Layer"; | ||
| 22 | import * as Path from "effect/Path"; | ||
| 23 | import * as Schema from "effect/Schema"; | ||
| 24 | |||
| 25 | import * as WorkspaceEntries from "./WorkspaceEntries.ts"; | ||
| 26 | import * as WorkspacePaths from "./WorkspacePaths.ts"; | ||
| 27 | |||
| 28 | const PROJECT_READ_FILE_MAX_BYTES = 1024 * 1024; | ||
| 29 | |||
| 30 | export class WorkspaceFileSystemError extends Schema.TaggedErrorClass<WorkspaceFileSystemError>()( | ||
| 31 | "WorkspaceFileSystemError", | ||
| 32 | { | ||
| 33 | cwd: Schema.String, | ||
| 34 | relativePath: Schema.optional(Schema.String), | ||
| 35 | operation: Schema.Literals([ | ||
| 36 | "workspaceFileSystem.readFile", | ||
| 37 | "workspaceFileSystem.makeDirectory", | ||
| 38 | "workspaceFileSystem.writeFile", | ||
| 39 | ]), | ||
| 40 | cause: Schema.Defect(), | ||
| 41 | }, | ||
| 42 | ) { | ||
| 43 | override get message(): string { | ||
| 44 | const target = this.relativePath ? `'${this.relativePath}' in '${this.cwd}'` : `'${this.cwd}'`; | ||
| 45 | return `Workspace file operation '${this.operation}' failed for ${target}.`; | ||
| 46 | } | ||
| 47 | } | ||
| 48 | |||
| 49 | /** Service tag for workspace file operations. */ | ||
| 50 | export class WorkspaceFileSystem extends Context.Service< | ||
| 51 | WorkspaceFileSystem, | ||
| 52 | { | ||
| 53 | /** Read a UTF-8 text file relative to the workspace root. */ | ||
| 54 | readonly readFile: ( | ||
| 55 | input: ProjectReadFileInput, | ||
| 56 | ) => Effect.Effect< | ||
| 57 | ProjectReadFileResult, | ||
| 58 | WorkspaceFileSystemError | WorkspacePaths.WorkspacePathOutsideRootError | ||
| 59 | >; | ||
| 60 | /** | ||
| 61 | * Write a file relative to the workspace root. | ||
| 62 | * | ||
| 63 | * Creates parent directories as needed and rejects paths that escape the | ||
| 64 | * workspace root. | ||
| 65 | */ | ||
| 66 | readonly writeFile: ( | ||
| 67 | input: ProjectWriteFileInput, | ||
| 68 | ) => Effect.Effect< | ||
| 69 | ProjectWriteFileResult, | ||
| 70 | WorkspaceFileSystemError | WorkspacePaths.WorkspacePathOutsideRootError | ||
| 71 | >; | ||
| 72 | } | ||
| 73 | >()("t3/workspace/WorkspaceFileSystem") {} | ||
| 74 | |||
| 75 | export const make = Effect.gen(function* () { | ||
| 76 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 77 | const path = yield* Path.Path; | ||
| 78 | const workspacePaths = yield* WorkspacePaths.WorkspacePaths; | ||
| 79 | const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; | ||
| 80 | |||
| 81 | const readFile: WorkspaceFileSystem["Service"]["readFile"] = Effect.fn( | ||
| 82 | "WorkspaceFileSystem.readFile", | ||
| 83 | )(function* (input) { | ||
| 84 | const target = yield* workspacePaths.resolveRelativePathWithinRoot({ | ||
| 85 | workspaceRoot: input.cwd, | ||
| 86 | relativePath: input.relativePath, | ||
| 87 | }); | ||
| 88 | |||
| 89 | return yield* Effect.tryPromise({ | ||
| 90 | try: async () => { | ||
| 91 | const [realWorkspaceRoot, realTargetPath] = await Promise.all([ | ||
| 92 | realpath(input.cwd), | ||
| 93 | realpath(target.absolutePath), | ||
| 94 | ]); | ||
| 95 | const relativeRealPath = path.relative(realWorkspaceRoot, realTargetPath); | ||
| 96 | if ( | ||
| 97 | relativeRealPath.startsWith(`..${path.sep}`) || | ||
| 98 | relativeRealPath === ".." || | ||
| 99 | path.isAbsolute(relativeRealPath) | ||
| 100 | ) { | ||
| 101 | throw new Error("Workspace file path resolves outside the project root."); | ||
| 102 | } | ||
| 103 | |||
| 104 | const handle = await open(realTargetPath, "r"); | ||
| 105 | try { | ||
| 106 | const stat = await handle.stat(); | ||
| 107 | if (!stat.isFile()) { | ||
| 108 | throw new Error("Workspace path is not a file."); | ||
| 109 | } | ||
| 110 | const bytesToRead = Math.min(stat.size, PROJECT_READ_FILE_MAX_BYTES); | ||
| 111 | const buffer = Buffer.alloc(bytesToRead); | ||
| 112 | const { bytesRead } = await handle.read(buffer, 0, bytesToRead, 0); | ||
| 113 | const fileBytes = buffer.subarray(0, bytesRead); | ||
| 114 | if (fileBytes.includes(0)) { | ||
| 115 | throw new Error("Binary files cannot be previewed as text."); | ||
| 116 | } | ||
| 117 | const contents = new TextDecoder("utf-8").decode(fileBytes); | ||
| 118 | return { | ||
| 119 | relativePath: target.relativePath, | ||
| 120 | contents, | ||
| 121 | byteLength: stat.size, | ||
| 122 | truncated: stat.size > PROJECT_READ_FILE_MAX_BYTES, | ||
| 123 | }; | ||
| 124 | } finally { | ||
| 125 | await handle.close(); | ||
| 126 | } | ||
| 127 | }, | ||
| 128 | catch: (cause) => | ||
| 129 | new WorkspaceFileSystemError({ | ||
| 130 | cwd: input.cwd, | ||
| 131 | relativePath: input.relativePath, | ||
| 132 | operation: "workspaceFileSystem.readFile", | ||
| 133 | cause, | ||
| 134 | }), | ||
| 135 | }); | ||
| 136 | }); | ||
| 137 | |||
| 138 | const writeFile: WorkspaceFileSystem["Service"]["writeFile"] = Effect.fn( | ||
| 139 | "WorkspaceFileSystem.writeFile", | ||
| 140 | )(function* (input) { | ||
| 141 | const target = yield* workspacePaths.resolveRelativePathWithinRoot({ | ||
| 142 | workspaceRoot: input.cwd, | ||
| 143 | relativePath: input.relativePath, | ||
| 144 | }); | ||
| 145 | |||
| 146 | yield* fileSystem.makeDirectory(path.dirname(target.absolutePath), { recursive: true }).pipe( | ||
| 147 | Effect.mapError( | ||
| 148 | (cause) => | ||
| 149 | new WorkspaceFileSystemError({ | ||
| 150 | cwd: input.cwd, | ||
| 151 | relativePath: input.relativePath, | ||
| 152 | operation: "workspaceFileSystem.makeDirectory", | ||
| 153 | cause, | ||
| 154 | }), | ||
| 155 | ), | ||
| 156 | ); | ||
| 157 | yield* fileSystem.writeFileString(target.absolutePath, input.contents).pipe( | ||
| 158 | Effect.mapError( | ||
| 159 | (cause) => | ||
| 160 | new WorkspaceFileSystemError({ | ||
| 161 | cwd: input.cwd, | ||
| 162 | relativePath: input.relativePath, | ||
| 163 | operation: "workspaceFileSystem.writeFile", | ||
| 164 | cause, | ||
| 165 | }), | ||
| 166 | ), | ||
| 167 | ); | ||
| 168 | yield* workspaceEntries.refresh(input.cwd); | ||
| 169 | return { relativePath: target.relativePath }; | ||
| 170 | }); | ||
| 171 | |||
| 172 | return WorkspaceFileSystem.of({ readFile, writeFile }); | ||
| 173 | }); | ||
| 174 | |||
| 175 | export const layer = Layer.effect(WorkspaceFileSystem, make); | ||
apps/server/src/workspace/WorkspacePaths.test.ts created+133| ... | @@ -0,0 +1,133 @@ | ||
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| 2 | import { it, describe, expect } from "@effect/vitest"; | ||
| 3 | import * as Effect from "effect/Effect"; | ||
| 4 | import * as FileSystem from "effect/FileSystem"; | ||
| 5 | import * as Layer from "effect/Layer"; | ||
| 6 | import * as Path from "effect/Path"; | ||
| 7 | |||
| 8 | import * as WorkspacePaths from "./WorkspacePaths.ts"; | ||
| 9 | |||
| 10 | const TestLayer = Layer.empty.pipe( | ||
| 11 | Layer.provideMerge(WorkspacePaths.layer), | ||
| 12 | Layer.provideMerge(NodeServices.layer), | ||
| 13 | ); | ||
| 14 | |||
| 15 | const makeTempDir = Effect.fn("makeTempDir")(function* () { | ||
| 16 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 17 | return yield* fileSystem.makeTempDirectoryScoped({ | ||
| 18 | prefix: "t3code-project-paths-", | ||
| 19 | }); | ||
| 20 | }); | ||
| 21 | |||
| 22 | const writeTextFile = Effect.fn("writeTextFile")(function* ( | ||
| 23 | cwd: string, | ||
| 24 | relativePath: string, | ||
| 25 | contents = "", | ||
| 26 | ) { | ||
| 27 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 28 | const path = yield* Path.Path; | ||
| 29 | const absolutePath = path.join(cwd, relativePath); | ||
| 30 | yield* fileSystem | ||
| 31 | .makeDirectory(path.dirname(absolutePath), { recursive: true }) | ||
| 32 | .pipe(Effect.orDie); | ||
| 33 | yield* fileSystem.writeFileString(absolutePath, contents).pipe(Effect.orDie); | ||
| 34 | }); | ||
| 35 | |||
| 36 | it.layer(TestLayer)("WorkspacePathsLive", (it) => { | ||
| 37 | describe("normalizeWorkspaceRoot", () => { | ||
| 38 | it.effect("resolves an existing directory", () => | ||
| 39 | Effect.gen(function* () { | ||
| 40 | const workspacePaths = yield* WorkspacePaths.WorkspacePaths; | ||
| 41 | const cwd = yield* makeTempDir(); | ||
| 42 | |||
| 43 | const resolved = yield* workspacePaths.normalizeWorkspaceRoot(cwd); | ||
| 44 | |||
| 45 | expect(resolved).toBe(cwd); | ||
| 46 | }), | ||
| 47 | ); | ||
| 48 | |||
| 49 | it.effect("rejects missing directories", () => | ||
| 50 | Effect.gen(function* () { | ||
| 51 | const workspacePaths = yield* WorkspacePaths.WorkspacePaths; | ||
| 52 | const cwd = yield* makeTempDir(); | ||
| 53 | const path = yield* Path.Path; | ||
| 54 | |||
| 55 | const error = yield* workspacePaths | ||
| 56 | .normalizeWorkspaceRoot(path.join(cwd, "missing")) | ||
| 57 | .pipe(Effect.flip); | ||
| 58 | |||
| 59 | expect(error.message).toContain("Workspace root does not exist:"); | ||
| 60 | }), | ||
| 61 | ); | ||
| 62 | |||
| 63 | it.effect("creates missing directories when createIfMissing is enabled", () => | ||
| 64 | Effect.gen(function* () { | ||
| 65 | const workspacePaths = yield* WorkspacePaths.WorkspacePaths; | ||
| 66 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 67 | const cwd = yield* makeTempDir(); | ||
| 68 | const path = yield* Path.Path; | ||
| 69 | const missingPath = path.join(cwd, "nested", "new-project"); | ||
| 70 | |||
| 71 | const resolved = yield* workspacePaths.normalizeWorkspaceRoot(missingPath, { | ||
| 72 | createIfMissing: true, | ||
| 73 | }); | ||
| 74 | const stat = yield* fileSystem.stat(resolved); | ||
| 75 | |||
| 76 | expect(resolved).toBe(missingPath); | ||
| 77 | expect(stat.type).toBe("Directory"); | ||
| 78 | }), | ||
| 79 | ); | ||
| 80 | |||
| 81 | it.effect("rejects file paths", () => | ||
| 82 | Effect.gen(function* () { | ||
| 83 | const workspacePaths = yield* WorkspacePaths.WorkspacePaths; | ||
| 84 | const cwd = yield* makeTempDir(); | ||
| 85 | const path = yield* Path.Path; | ||
| 86 | const filePath = path.join(cwd, "README.md"); | ||
| 87 | yield* writeTextFile(cwd, "README.md", "# hi\n"); | ||
| 88 | |||
| 89 | const error = yield* workspacePaths.normalizeWorkspaceRoot(filePath).pipe(Effect.flip); | ||
| 90 | |||
| 91 | expect(error.message).toContain("Workspace root is not a directory:"); | ||
| 92 | }), | ||
| 93 | ); | ||
| 94 | }); | ||
| 95 | |||
| 96 | describe("resolveRelativePathWithinRoot", () => { | ||
| 97 | it.effect("resolves relative paths inside the workspace root", () => | ||
| 98 | Effect.gen(function* () { | ||
| 99 | const workspacePaths = yield* WorkspacePaths.WorkspacePaths; | ||
| 100 | const cwd = yield* makeTempDir(); | ||
| 101 | const path = yield* Path.Path; | ||
| 102 | |||
| 103 | const resolved = yield* workspacePaths.resolveRelativePathWithinRoot({ | ||
| 104 | workspaceRoot: cwd, | ||
| 105 | relativePath: "plans/effect-rpc.md", | ||
| 106 | }); | ||
| 107 | |||
| 108 | expect(resolved).toEqual({ | ||
| 109 | absolutePath: path.join(cwd, "plans/effect-rpc.md"), | ||
| 110 | relativePath: "plans/effect-rpc.md", | ||
| 111 | }); | ||
| 112 | }), | ||
| 113 | ); | ||
| 114 | |||
| 115 | it.effect("rejects paths that escape the workspace root", () => | ||
| 116 | Effect.gen(function* () { | ||
| 117 | const workspacePaths = yield* WorkspacePaths.WorkspacePaths; | ||
| 118 | const cwd = yield* makeTempDir(); | ||
| 119 | |||
| 120 | const error = yield* workspacePaths | ||
| 121 | .resolveRelativePathWithinRoot({ | ||
| 122 | workspaceRoot: cwd, | ||
| 123 | relativePath: "../escape.md", | ||
| 124 | }) | ||
| 125 | .pipe(Effect.flip); | ||
| 126 | |||
| 127 | expect(error.message).toContain( | ||
| 128 | "Workspace file path must be relative to the project root: ../escape.md", | ||
| 129 | ); | ||
| 130 | }), | ||
| 131 | ); | ||
| 132 | }); | ||
| 133 | }); | ||
apps/server/src/workspace/WorkspacePaths.ts created+191| ... | @@ -0,0 +1,191 @@ | ||
| 1 | /** | ||
| 2 | * WorkspacePaths - Effect service contract for workspace path handling. | ||
| 3 | * | ||
| 4 | * Owns normalization and validation of workspace roots plus safe resolution of | ||
| 5 | * workspace-root-relative paths. | ||
| 6 | * | ||
| 7 | * @module WorkspacePaths | ||
| 8 | */ | ||
| 9 | import { homedir } from "node:os"; | ||
| 10 | |||
| 11 | import * as Context from "effect/Context"; | ||
| 12 | import * as Effect from "effect/Effect"; | ||
| 13 | import * as FileSystem from "effect/FileSystem"; | ||
| 14 | import * as Layer from "effect/Layer"; | ||
| 15 | import * as Path from "effect/Path"; | ||
| 16 | import * as Schema from "effect/Schema"; | ||
| 17 | |||
| 18 | export class WorkspaceRootNotExistsError extends Schema.TaggedErrorClass<WorkspaceRootNotExistsError>()( | ||
| 19 | "WorkspaceRootNotExistsError", | ||
| 20 | { | ||
| 21 | workspaceRoot: Schema.String, | ||
| 22 | normalizedWorkspaceRoot: Schema.String, | ||
| 23 | }, | ||
| 24 | ) { | ||
| 25 | override get message(): string { | ||
| 26 | return `Workspace root does not exist: ${this.normalizedWorkspaceRoot}`; | ||
| 27 | } | ||
| 28 | } | ||
| 29 | |||
| 30 | export class WorkspaceRootCreateFailedError extends Schema.TaggedErrorClass<WorkspaceRootCreateFailedError>()( | ||
| 31 | "WorkspaceRootCreateFailedError", | ||
| 32 | { | ||
| 33 | workspaceRoot: Schema.String, | ||
| 34 | normalizedWorkspaceRoot: Schema.String, | ||
| 35 | cause: Schema.Defect(), | ||
| 36 | }, | ||
| 37 | ) { | ||
| 38 | override get message(): string { | ||
| 39 | return `Failed to create workspace root: ${this.normalizedWorkspaceRoot}`; | ||
| 40 | } | ||
| 41 | } | ||
| 42 | |||
| 43 | export class WorkspaceRootNotDirectoryError extends Schema.TaggedErrorClass<WorkspaceRootNotDirectoryError>()( | ||
| 44 | "WorkspaceRootNotDirectoryError", | ||
| 45 | { | ||
| 46 | workspaceRoot: Schema.String, | ||
| 47 | normalizedWorkspaceRoot: Schema.String, | ||
| 48 | }, | ||
| 49 | ) { | ||
| 50 | override get message(): string { | ||
| 51 | return `Workspace root is not a directory: ${this.normalizedWorkspaceRoot}`; | ||
| 52 | } | ||
| 53 | } | ||
| 54 | |||
| 55 | export class WorkspacePathOutsideRootError extends Schema.TaggedErrorClass<WorkspacePathOutsideRootError>()( | ||
| 56 | "WorkspacePathOutsideRootError", | ||
| 57 | { | ||
| 58 | workspaceRoot: Schema.String, | ||
| 59 | relativePath: Schema.String, | ||
| 60 | }, | ||
| 61 | ) { | ||
| 62 | override get message(): string { | ||
| 63 | return `Workspace file path must be relative to the project root: ${this.relativePath}`; | ||
| 64 | } | ||
| 65 | } | ||
| 66 | |||
| 67 | export const WorkspacePathsError = Schema.Union([ | ||
| 68 | WorkspaceRootNotExistsError, | ||
| 69 | WorkspaceRootCreateFailedError, | ||
| 70 | WorkspaceRootNotDirectoryError, | ||
| 71 | WorkspacePathOutsideRootError, | ||
| 72 | ]); | ||
| 73 | export type WorkspacePathsError = typeof WorkspacePathsError.Type; | ||
| 74 | |||
| 75 | /** Service tag for workspace path normalization and resolution. */ | ||
| 76 | export class WorkspacePaths extends Context.Service< | ||
| 77 | WorkspacePaths, | ||
| 78 | { | ||
| 79 | /** Normalize a user-provided workspace root and verify it exists as a directory. */ | ||
| 80 | readonly normalizeWorkspaceRoot: ( | ||
| 81 | workspaceRoot: string, | ||
| 82 | options?: { readonly createIfMissing?: boolean }, | ||
| 83 | ) => Effect.Effect< | ||
| 84 | string, | ||
| 85 | WorkspaceRootNotExistsError | WorkspaceRootCreateFailedError | WorkspaceRootNotDirectoryError | ||
| 86 | >; | ||
| 87 | /** | ||
| 88 | * Resolve a relative path within a validated workspace root. | ||
| 89 | * | ||
| 90 | * Rejects absolute paths and traversal attempts outside the workspace root. | ||
| 91 | */ | ||
| 92 | readonly resolveRelativePathWithinRoot: (input: { | ||
| 93 | workspaceRoot: string; | ||
| 94 | relativePath: string; | ||
| 95 | }) => Effect.Effect< | ||
| 96 | { absolutePath: string; relativePath: string }, | ||
| 97 | WorkspacePathOutsideRootError | ||
| 98 | >; | ||
| 99 | } | ||
| 100 | >()("t3/workspace/WorkspacePaths") {} | ||
| 101 | |||
| 102 | function toPosixRelativePath(input: string): string { | ||
| 103 | return input.replaceAll("\\", "/"); | ||
| 104 | } | ||
| 105 | |||
| 106 | function expandHomePath(input: string, path: Path.Path): string { | ||
| 107 | if (input === "~") { | ||
| 108 | return homedir(); | ||
| 109 | } | ||
| 110 | if (input.startsWith("~/") || input.startsWith("~\\")) { | ||
| 111 | return path.join(homedir(), input.slice(2)); | ||
| 112 | } | ||
| 113 | return input; | ||
| 114 | } | ||
| 115 | |||
| 116 | export const make = Effect.gen(function* () { | ||
| 117 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 118 | const path = yield* Path.Path; | ||
| 119 | |||
| 120 | const normalizeWorkspaceRoot: WorkspacePaths["Service"]["normalizeWorkspaceRoot"] = Effect.fn( | ||
| 121 | "WorkspacePaths.normalizeWorkspaceRoot", | ||
| 122 | )(function* (workspaceRoot, options) { | ||
| 123 | const normalizedWorkspaceRoot = path.resolve(expandHomePath(workspaceRoot.trim(), path)); | ||
| 124 | let workspaceStat = yield* fileSystem | ||
| 125 | .stat(normalizedWorkspaceRoot) | ||
| 126 | .pipe(Effect.orElseSucceed(() => null)); | ||
| 127 | if (!workspaceStat && options?.createIfMissing) { | ||
| 128 | yield* fileSystem.makeDirectory(normalizedWorkspaceRoot, { recursive: true }).pipe( | ||
| 129 | Effect.mapError( | ||
| 130 | (cause) => | ||
| 131 | new WorkspaceRootCreateFailedError({ | ||
| 132 | workspaceRoot, | ||
| 133 | normalizedWorkspaceRoot, | ||
| 134 | cause, | ||
| 135 | }), | ||
| 136 | ), | ||
| 137 | ); | ||
| 138 | workspaceStat = yield* fileSystem | ||
| 139 | .stat(normalizedWorkspaceRoot) | ||
| 140 | .pipe(Effect.orElseSucceed(() => null)); | ||
| 141 | } | ||
| 142 | if (!workspaceStat) { | ||
| 143 | return yield* new WorkspaceRootNotExistsError({ | ||
| 144 | workspaceRoot, | ||
| 145 | normalizedWorkspaceRoot, | ||
| 146 | }); | ||
| 147 | } | ||
| 148 | if (workspaceStat.type !== "Directory") { | ||
| 149 | return yield* new WorkspaceRootNotDirectoryError({ | ||
| 150 | workspaceRoot, | ||
| 151 | normalizedWorkspaceRoot, | ||
| 152 | }); | ||
| 153 | } | ||
| 154 | return normalizedWorkspaceRoot; | ||
| 155 | }); | ||
| 156 | |||
| 157 | const resolveRelativePathWithinRoot: WorkspacePaths["Service"]["resolveRelativePathWithinRoot"] = | ||
| 158 | Effect.fn("WorkspacePaths.resolveRelativePathWithinRoot")(function* (input) { | ||
| 159 | const normalizedInputPath = input.relativePath.trim(); | ||
| 160 | if (path.isAbsolute(normalizedInputPath)) { | ||
| 161 | return yield* new WorkspacePathOutsideRootError({ | ||
| 162 | workspaceRoot: input.workspaceRoot, | ||
| 163 | relativePath: input.relativePath, | ||
| 164 | }); | ||
| 165 | } | ||
| 166 | |||
| 167 | const absolutePath = path.resolve(input.workspaceRoot, normalizedInputPath); | ||
| 168 | const relativeToRoot = toPosixRelativePath(path.relative(input.workspaceRoot, absolutePath)); | ||
| 169 | if ( | ||
| 170 | relativeToRoot.length === 0 || | ||
| 171 | relativeToRoot === "." || | ||
| 172 | relativeToRoot.startsWith("../") || | ||
| 173 | relativeToRoot === ".." || | ||
| 174 | path.isAbsolute(relativeToRoot) | ||
| 175 | ) { | ||
| 176 | return yield* new WorkspacePathOutsideRootError({ | ||
| 177 | workspaceRoot: input.workspaceRoot, | ||
| 178 | relativePath: input.relativePath, | ||
| 179 | }); | ||
| 180 | } | ||
| 181 | |||
| 182 | return { | ||
| 183 | absolutePath, | ||
| 184 | relativePath: relativeToRoot, | ||
| 185 | }; | ||
| 186 | }); | ||
| 187 | |||
| 188 | return WorkspacePaths.of({ normalizeWorkspaceRoot, resolveRelativePathWithinRoot }); | ||
| 189 | }); | ||
| 190 | |||
| 191 | export const layer = Layer.effect(WorkspacePaths, make); | ||
apps/server/src/workspace/WorkspaceSearchIndex.ts+55-50| ... | @@ -182,64 +182,69 @@ const waitForScan = Effect.fn("WorkspaceSearchIndex.waitForScan")(function* ( | ... | @@ -182,64 +182,69 @@ const waitForScan = Effect.fn("WorkspaceSearchIndex.waitForScan")(function* ( |
| 182 | ); | 182 | ); |
| 183 | }); | 183 | }); |
| 184 | 184 | ||
| 185 | const makeWorkspaceSearchIndex = (cwd: string) => | 185 | export const make = Effect.fn("WorkspaceSearchIndex.make")(function* (cwd: string) { |
| 186 | Effect.acquireRelease(createFinder(cwd), (finder) => Effect.sync(() => finder.destroy())).pipe( | 186 | const finder = yield* Effect.acquireRelease(createFinder(cwd), (finder) => |
| 187 | Effect.tap((finder) => waitForScan(cwd, finder)), | 187 | Effect.sync(() => finder.destroy()), |
| 188 | Effect.map((finder) => { | 188 | ); |
| 189 | const runMixedSearch = Effect.fn("WorkspaceSearchIndex.runMixedSearch")(function* ( | 189 | yield* waitForScan(cwd, finder); |
| 190 | query: string, | 190 | |
| 191 | pageSize: number, | 191 | const runMixedSearch = Effect.fn("WorkspaceSearchIndex.runMixedSearch")(function* ( |
| 192 | ) { | 192 | query: string, |
| 193 | const result = yield* Effect.sync(() => finder.mixedSearch(query, { pageSize })); | 193 | pageSize: number, |
| 194 | if (!result.ok) { | 194 | ) { |
| 195 | return yield* new WorkspaceSearchIndexSearchFailed({ cwd, reason: result.error }); | 195 | const result = yield* Effect.sync(() => finder.mixedSearch(query, { pageSize })); |
| 196 | } | 196 | if (!result.ok) { |
| 197 | return result.value; | 197 | return yield* new WorkspaceSearchIndexSearchFailed({ cwd, reason: result.error }); |
| 198 | }); | 198 | } |
| 199 | 199 | return result.value; | |
| 200 | const refresh: WorkspaceSearchIndex["Service"]["refresh"] = Effect.fn( | 200 | }); |
| 201 | "WorkspaceSearchIndex.refresh", | ||
| 202 | )(function* () { | ||
| 203 | const result = yield* Effect.sync(() => finder.scanFiles()); | ||
| 204 | if (!result.ok) { | ||
| 205 | return yield* new WorkspaceSearchIndexRefreshFailed({ cwd, reason: result.error }); | ||
| 206 | } | ||
| 207 | yield* waitForScan(cwd, finder); | ||
| 208 | }); | ||
| 209 | |||
| 210 | const list: WorkspaceSearchIndex["Service"]["list"] = Effect.fn("WorkspaceSearchIndex.list")( | ||
| 211 | function* () { | ||
| 212 | const result = yield* runMixedSearch("", WORKSPACE_INDEX_PAGE_SIZE); | ||
| 213 | const mapped = mapMixedSearchResult(result, WORKSPACE_INDEX_MAX_ENTRIES); | ||
| 214 | const sortedEntries = withDirectoryAncestors(mapped.entries).toSorted((left, right) => | ||
| 215 | left.path.localeCompare(right.path), | ||
| 216 | ); | ||
| 217 | const entries = sortedEntries.slice(0, WORKSPACE_INDEX_MAX_ENTRIES); | ||
| 218 | return { | ||
| 219 | entries, | ||
| 220 | truncated: mapped.truncated || entries.length < sortedEntries.length, | ||
| 221 | }; | ||
| 222 | }, | ||
| 223 | ); | ||
| 224 | 201 | ||
| 225 | const search: WorkspaceSearchIndex["Service"]["search"] = Effect.fn( | 202 | const refresh: WorkspaceSearchIndex["Service"]["refresh"] = Effect.fn( |
| 226 | "WorkspaceSearchIndex.search", | 203 | "WorkspaceSearchIndex.refresh", |
| 227 | )(function* (query, limit) { | 204 | )(function* () { |
| 228 | const result = yield* runMixedSearch(query, Math.max(1, limit + 1)); | 205 | const result = yield* Effect.sync(() => finder.scanFiles()); |
| 229 | return mapMixedSearchResult(result, limit); | 206 | if (!result.ok) { |
| 230 | }); | 207 | return yield* new WorkspaceSearchIndexRefreshFailed({ cwd, reason: result.error }); |
| 208 | } | ||
| 209 | yield* waitForScan(cwd, finder); | ||
| 210 | }); | ||
| 231 | 211 | ||
| 232 | return WorkspaceSearchIndex.of({ list, refresh, search }); | 212 | const list: WorkspaceSearchIndex["Service"]["list"] = Effect.fn("WorkspaceSearchIndex.list")( |
| 233 | }), | 213 | function* () { |
| 214 | const result = yield* runMixedSearch("", WORKSPACE_INDEX_PAGE_SIZE); | ||
| 215 | const mapped = mapMixedSearchResult(result, WORKSPACE_INDEX_MAX_ENTRIES); | ||
| 216 | const sortedEntries = withDirectoryAncestors(mapped.entries).toSorted((left, right) => | ||
| 217 | left.path.localeCompare(right.path), | ||
| 218 | ); | ||
| 219 | const entries = sortedEntries.slice(0, WORKSPACE_INDEX_MAX_ENTRIES); | ||
| 220 | return { | ||
| 221 | entries, | ||
| 222 | truncated: mapped.truncated || entries.length < sortedEntries.length, | ||
| 223 | }; | ||
| 224 | }, | ||
| 234 | ); | 225 | ); |
| 235 | 226 | ||
| 236 | const workspaceSearchIndexLayer = (cwd: string) => | 227 | const search: WorkspaceSearchIndex["Service"]["search"] = Effect.fn( |
| 237 | Layer.effect(WorkspaceSearchIndex, makeWorkspaceSearchIndex(cwd)); | 228 | "WorkspaceSearchIndex.search", |
| 229 | )(function* (query, limit) { | ||
| 230 | const result = yield* runMixedSearch(query, Math.max(1, limit + 1)); | ||
| 231 | return mapMixedSearchResult(result, limit); | ||
| 232 | }); | ||
| 233 | |||
| 234 | return WorkspaceSearchIndex.of({ list, refresh, search }); | ||
| 235 | }); | ||
| 236 | |||
| 237 | /** | ||
| 238 | * A layer factory is required because every index is scoped to a concrete | ||
| 239 | * workspace root. WorkspaceSearchIndexMap owns memoization and idle cleanup; | ||
| 240 | * using a default cwd here would mix resources from different workspaces. | ||
| 241 | */ | ||
| 242 | export const layer = (cwd: string) => Layer.effect(WorkspaceSearchIndex, make(cwd)); | ||
| 238 | 243 | ||
| 239 | export class WorkspaceSearchIndexMap extends LayerMap.Service<WorkspaceSearchIndexMap>()( | 244 | export class WorkspaceSearchIndexMap extends LayerMap.Service<WorkspaceSearchIndexMap>()( |
| 240 | "t3/workspace/WorkspaceSearchIndexMap", | 245 | "t3/workspace/WorkspaceSearchIndexMap", |
| 241 | { | 246 | { |
| 242 | lookup: workspaceSearchIndexLayer, | 247 | lookup: layer, |
| 243 | idleTimeToLive: WORKSPACE_INDEX_IDLE_TTL, | 248 | idleTimeToLive: WORKSPACE_INDEX_IDLE_TTL, |
| 244 | }, | 249 | }, |
| 245 | ) {} | 250 | ) {} |
apps/server/src/ws.ts+9-9| ... | @@ -79,15 +79,15 @@ import * as PreviewManager from "./preview/Manager.ts"; | ... | @@ -79,15 +79,15 @@ import * as PreviewManager from "./preview/Manager.ts"; |
| 79 | import { issueAssetUrl } from "./assets/AssetAccess.ts"; | 79 | import { issueAssetUrl } from "./assets/AssetAccess.ts"; |
| 80 | import * as PortScanner from "./preview/PortScanner.ts"; | 80 | import * as PortScanner from "./preview/PortScanner.ts"; |
| 81 | import * as WorkspaceEntries from "./workspace/WorkspaceEntries.ts"; | 81 | import * as WorkspaceEntries from "./workspace/WorkspaceEntries.ts"; |
| 82 | import * as WorkspaceFileSystem from "./workspace/Services/WorkspaceFileSystem.ts"; | 82 | import * as WorkspaceFileSystem from "./workspace/WorkspaceFileSystem.ts"; |
| 83 | import * as WorkspacePaths from "./workspace/Services/WorkspacePaths.ts"; | 83 | import * as WorkspacePaths from "./workspace/WorkspacePaths.ts"; |
| 84 | import * as VcsStatusBroadcaster from "./vcs/VcsStatusBroadcaster.ts"; | 84 | import * as VcsStatusBroadcaster from "./vcs/VcsStatusBroadcaster.ts"; |
| 85 | import * as VcsProvisioningService from "./vcs/VcsProvisioningService.ts"; | 85 | import * as VcsProvisioningService from "./vcs/VcsProvisioningService.ts"; |
| 86 | import * as GitWorkflowService from "./git/GitWorkflowService.ts"; | 86 | import * as GitWorkflowService from "./git/GitWorkflowService.ts"; |
| 87 | import * as ReviewService from "./review/ReviewService.ts"; | 87 | import * as ReviewService from "./review/ReviewService.ts"; |
| 88 | import * as ProjectSetupScriptRunner from "./project/Services/ProjectSetupScriptRunner.ts"; | 88 | import * as ProjectSetupScriptRunner from "./project/ProjectSetupScriptRunner.ts"; |
| 89 | import * as RepositoryIdentityResolver from "./project/Services/RepositoryIdentityResolver.ts"; | 89 | import * as RepositoryIdentityResolver from "./project/RepositoryIdentityResolver.ts"; |
| 90 | import * as ServerEnvironment from "./environment/Services/ServerEnvironment.ts"; | 90 | import * as ServerEnvironment from "./environment/ServerEnvironment.ts"; |
| 91 | import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; | 91 | import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; |
| 92 | import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts"; | 92 | import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts"; |
| 93 | import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts"; | 93 | import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts"; |
| ... | @@ -1190,7 +1190,7 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) => | ... | @@ -1190,7 +1190,7 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) => |
| 1190 | Effect.mapError( | 1190 | Effect.mapError( |
| 1191 | (cause) => | 1191 | (cause) => |
| 1192 | new ProjectSearchEntriesError({ | 1192 | new ProjectSearchEntriesError({ |
| 1193 | message: `Failed to search workspace entries: ${cause.detail}`, | 1193 | message: "Failed to search workspace entries.", |
| 1194 | cause, | 1194 | cause, |
| 1195 | }), | 1195 | }), |
| 1196 | ), | 1196 | ), |
| ... | @@ -1204,7 +1204,7 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) => | ... | @@ -1204,7 +1204,7 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) => |
| 1204 | Effect.mapError( | 1204 | Effect.mapError( |
| 1205 | (cause) => | 1205 | (cause) => |
| 1206 | new ProjectListEntriesError({ | 1206 | new ProjectListEntriesError({ |
| 1207 | message: `Failed to list workspace entries: ${cause.detail}`, | 1207 | message: "Failed to list workspace entries.", |
| 1208 | cause, | 1208 | cause, |
| 1209 | }), | 1209 | }), |
| 1210 | ), | 1210 | ), |
| ... | @@ -1218,7 +1218,7 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) => | ... | @@ -1218,7 +1218,7 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) => |
| 1218 | Effect.mapError((cause) => { | 1218 | Effect.mapError((cause) => { |
| 1219 | const message = isWorkspacePathOutsideRootError(cause) | 1219 | const message = isWorkspacePathOutsideRootError(cause) |
| 1220 | ? "Workspace file path must stay within the project root." | 1220 | ? "Workspace file path must stay within the project root." |
| 1221 | : `Failed to read workspace file: ${cause.detail}`; | 1221 | : "Failed to read workspace file."; |
| 1222 | return new ProjectReadFileError({ message, cause }); | 1222 | return new ProjectReadFileError({ message, cause }); |
| 1223 | }), | 1223 | }), |
| 1224 | ), | 1224 | ), |
| ... | @@ -1251,7 +1251,7 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) => | ... | @@ -1251,7 +1251,7 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) => |
| 1251 | Effect.mapError( | 1251 | Effect.mapError( |
| 1252 | (cause) => | 1252 | (cause) => |
| 1253 | new FilesystemBrowseError({ | 1253 | new FilesystemBrowseError({ |
| 1254 | message: cause.detail, | 1254 | message: "Failed to browse the filesystem.", |
| 1255 | cause, | 1255 | cause, |
| 1256 | }), | 1256 | }), |
| 1257 | ), | 1257 | ), |