authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-06-20 00:12:46-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-06-20 00:12:46-07:00
logb0a3a504481ab4d8f2da0343e5a4258c10d03c73
treecdb551d95e0637cf1363696e910ae3e97a335d9c
parent79c57170c7fb9cc1844427aed93c5f297fb110b5
signature Signed by PGP key B5690EEEBB952194

[codex] Refactor project and workspace Effect services (#3190)

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 {
46import { ProviderService } from "../src/provider/Services/ProviderService.ts";46import { ProviderService } from "../src/provider/Services/ProviderService.ts";
47import { AnalyticsService } from "../src/telemetry/Services/AnalyticsService.ts";47import { AnalyticsService } from "../src/telemetry/Services/AnalyticsService.ts";
48import { CheckpointReactorLive } from "../src/orchestration/Layers/CheckpointReactor.ts";48import { CheckpointReactorLive } from "../src/orchestration/Layers/CheckpointReactor.ts";
49import { RepositoryIdentityResolverLive } from "../src/project/Layers/RepositoryIdentityResolver.ts";49import * as RepositoryIdentityResolver from "../src/project/RepositoryIdentityResolver.ts";
50import { OrchestrationEngineLive } from "../src/orchestration/Layers/OrchestrationEngine.ts";50import { OrchestrationEngineLive } from "../src/orchestration/Layers/OrchestrationEngine.ts";
51import { OrchestrationProjectionPipelineLive } from "../src/orchestration/Layers/ProjectionPipeline.ts";51import { OrchestrationProjectionPipelineLive } from "../src/orchestration/Layers/ProjectionPipeline.ts";
52import { OrchestrationProjectionSnapshotQueryLive } from "../src/orchestration/Layers/ProjectionSnapshotQuery.ts";52import { 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";
73import { deriveServerPaths, ServerConfig } from "../src/config.ts";73import { deriveServerPaths, ServerConfig } from "../src/config.ts";
74import * as WorkspaceEntries from "../src/workspace/WorkspaceEntries.ts";74import * as WorkspaceEntries from "../src/workspace/WorkspaceEntries.ts";
75import { WorkspacePathsLive } from "../src/workspace/Layers/WorkspacePaths.ts";75import * as WorkspacePaths from "../src/workspace/WorkspacePaths.ts";
76import * as VcsDriverRegistry from "../src/vcs/VcsDriverRegistry.ts";76import * as VcsDriverRegistry from "../src/vcs/VcsDriverRegistry.ts";
77import { VcsStatusBroadcaster } from "../src/vcs/VcsStatusBroadcaster.ts";77import { VcsStatusBroadcaster } from "../src/vcs/VcsStatusBroadcaster.ts";
78import { GitWorkflowService } from "../src/git/GitWorkflowService.ts";78import { 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";
7import * as Path from "effect/Path";7import * as Path from "effect/Path";
88
9import * as ServerSecretStore from "../auth/ServerSecretStore.ts";9import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
10import { ServerConfig } from "../config.ts";10import * as ServerConfig from "../config.ts";
11import { ProjectFaviconResolverLive } from "../project/Layers/ProjectFaviconResolver.ts";11import * as ProjectFaviconResolver from "../project/ProjectFaviconResolver.ts";
12import { WorkspacePathsLive } from "../workspace/Layers/WorkspacePaths.ts";12import * as WorkspacePaths from "../workspace/WorkspacePaths.ts";
13import { ASSET_ROUTE_PREFIX, issueAssetUrl, resolveAsset } from "./AssetAccess.ts";13import { ASSET_ROUTE_PREFIX, issueAssetUrl, resolveAsset } from "./AssetAccess.ts";
1414
15const configLayer = ServerConfig.layerTest(process.cwd(), {15const configLayer = ServerConfig.ServerConfig.layerTest(process.cwd(), {
16 prefix: "t3-asset-access-test-",16 prefix: "t3-asset-access-test-",
17});17});
18const testLayer = Layer.mergeAll(18const 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));
2424
...@@ -127,7 +127,7 @@ describe("AssetAccess", () => {...@@ -127,7 +127,7 @@ describe("AssetAccess", () => {
127127
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 {
22import { ServerSecretStore } from "../auth/ServerSecretStore.ts";22import { ServerSecretStore } from "../auth/ServerSecretStore.ts";
23import { resolveAttachmentPathById } from "../attachmentStore.ts";23import { resolveAttachmentPathById } from "../attachmentStore.ts";
24import { ServerConfig } from "../config.ts";24import { ServerConfig } from "../config.ts";
25import { ProjectFaviconResolver } from "../project/Services/ProjectFaviconResolver.ts";25import * as ProjectFaviconResolver from "../project/ProjectFaviconResolver.ts";
26import { WorkspacePaths } from "../workspace/Services/WorkspacePaths.ts";26import * as WorkspacePaths from "../workspace/WorkspacePaths.ts";
2727
28export const ASSET_ROUTE_PREFIX = "/api/assets";28export const ASSET_ROUTE_PREFIX = "/api/assets";
29export 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>`;29export 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) =>
103const resolveCanonicalWorkspaceFile = Effect.fn("AssetAccess.resolveCanonicalWorkspaceFile")(103const 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* workspacePaths107 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* workspacePaths202 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.
2import * as NodeHttp from "node:http";2import { createServer } from "node:http";
3import { existsSync, mkdirSync, mkdtempSync, writeFileSync } from "node:fs";3import { existsSync, mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
4import { tmpdir } from "node:os";4import { tmpdir } from "node:os";
5import { join } from "node:path";5import { 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
25import { OrchestrationLayerLive } from "./orchestration/runtimeLayer.ts";25import { OrchestrationLayerLive } from "./orchestration/runtimeLayer.ts";
26import { orchestrationHttpApiLayer } from "./orchestration/http.ts";26import { orchestrationHttpApiLayer } from "./orchestration/http.ts";
27import { layerConfig as SqlitePersistenceLayerLive } from "./persistence/Layers/Sqlite.ts";27import { layerConfig as SqlitePersistenceLayerLive } from "./persistence/Layers/Sqlite.ts";
28import { RepositoryIdentityResolverLive } from "./project/Layers/RepositoryIdentityResolver.ts";28import * as RepositoryIdentityResolver from "./project/RepositoryIdentityResolver.ts";
29import {29import {
30 makePersistedServerRuntimeState,30 makePersistedServerRuntimeState,
31 persistServerRuntimeState,31 persistServerRuntimeState,
32} from "./serverRuntimeState.ts";32} from "./serverRuntimeState.ts";
33import { WorkspacePathsLive } from "./workspace/Layers/WorkspacePaths.ts";33import * as WorkspacePaths from "./workspace/WorkspacePaths.ts";
34import * as ServerSecretStore from "./auth/ServerSecretStore.ts";34import * as ServerSecretStore from "./auth/ServerSecretStore.ts";
35import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts";35import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts";
36import { environmentAuthenticatedAuthLayer } from "./auth/http.ts";36import { environmentAuthenticatedAuthLayer } from "./auth/http.ts";
...@@ -90,10 +90,10 @@ const makeCliTestServerConfig = (baseDir: string) =>...@@ -90,10 +90,10 @@ const makeCliTestServerConfig = (baseDir: string) =>
90const makeProjectPersistenceLayer = (config: ServerConfig.ServerConfig["Service"]) =>90const 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)));
9898
99const readPersistedSnapshot = (baseDir: string) =>99const 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";
32import { relayUrlConfig } from "../cloud/publicConfig.ts";32import { relayUrlConfig } from "../cloud/publicConfig.ts";
33import { headlessRelayClientTracingLayer } from "../cloud/relayTracing.ts";33import { headlessRelayClientTracingLayer } from "../cloud/relayTracing.ts";
34import * as ServerConfig from "../config.ts";34import * as ServerConfig from "../config.ts";
35import { ServerEnvironmentLive } from "../environment/Layers/ServerEnvironment.ts";35import * as ServerEnvironment from "../environment/ServerEnvironment.ts";
36import * as ServerEnvironment from "../environment/Services/ServerEnvironment.ts";
37import { readPersistedServerRuntimeState } from "../serverRuntimeState.ts";36import { readPersistedServerRuntimeState } from "../serverRuntimeState.ts";
38import { projectLocationFlags, resolveCliAuthConfig } from "./config.ts";37import { projectLocationFlags, resolveCliAuthConfig } from "./config.ts";
3938
...@@ -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
31import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts";31import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts";
32import { OrchestrationLayerLive } from "../orchestration/runtimeLayer.ts";32import { OrchestrationLayerLive } from "../orchestration/runtimeLayer.ts";
33import { layerConfig as SqlitePersistenceLayerLive } from "../persistence/Layers/Sqlite.ts";33import { layerConfig as SqlitePersistenceLayerLive } from "../persistence/Layers/Sqlite.ts";
34import { RepositoryIdentityResolverLive } from "../project/Layers/RepositoryIdentityResolver.ts";34import * as RepositoryIdentityResolver from "../project/RepositoryIdentityResolver.ts";
35import * as ServerRuntimeStartup from "../serverRuntimeStartup.ts";35import * as ServerRuntimeStartup from "../serverRuntimeStartup.ts";
36import {36import {
37 clearPersistedServerRuntimeState,37 clearPersistedServerRuntimeState,
38 readPersistedServerRuntimeState,38 readPersistedServerRuntimeState,
39} from "../serverRuntimeState.ts";39} from "../serverRuntimeState.ts";
40import { WorkspacePathsLive } from "../workspace/Layers/WorkspacePaths.ts";40import * as WorkspacePaths from "../workspace/WorkspacePaths.ts";
41import * as WorkspacePaths from "../workspace/Services/WorkspacePaths.ts";
42import { type CliAuthLocationFlags, projectLocationFlags, resolveCliAuthConfig } from "./config.ts";41import { type CliAuthLocationFlags, projectLocationFlags, resolveCliAuthConfig } from "./config.ts";
4342
44type ProjectMutationTarget = {43type ProjectMutationTarget = {
...@@ -68,9 +67,9 @@ const projectCommandUuid = Crypto.Crypto.pipe(...@@ -68,9 +67,9 @@ const projectCommandUuid = Crypto.Crypto.pipe(
68);67);
6968
70const ProjectCliRuntimeLive = Layer.mergeAll(69const 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";
9import { RelayClientTracer } from "@t3tools/shared/relayTracing";9import { RelayClientTracer } from "@t3tools/shared/relayTracing";
10import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts";10import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts";
11import * as ServerSecretStore from "../auth/ServerSecretStore.ts";11import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
12import * as ServerEnvironment from "../environment/Services/ServerEnvironment.ts";12import * as ServerEnvironment from "../environment/ServerEnvironment.ts";
13import * as CliTokenManager from "./CliTokenManager.ts";13import * as CliTokenManager from "./CliTokenManager.ts";
14import { consumeCloudReplayGuards, reconcileDesiredCloudLink } from "./http.ts";14import { consumeCloudReplayGuards, reconcileDesiredCloudLink } from "./http.ts";
15import * as ManagedEndpointRuntime from "./ManagedEndpointRuntime.ts";15import * as ManagedEndpointRuntime from "./ManagedEndpointRuntime.ts";
apps/server/src/cloud/http.ts+3-3
...@@ -1,4 +1,4 @@...@@ -1,4 +1,4 @@
1import * as NodeCrypto from "node:crypto";1import { createPublicKey } from "node:crypto";
2import {2import {
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";
55import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts";55import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts";
56import * as ServerSecretStore from "../auth/ServerSecretStore.ts";56import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
57import { requireEnvironmentScope } from "../auth/http.ts";57import { requireEnvironmentScope } from "../auth/http.ts";
58import * as ServerEnvironment from "../environment/Services/ServerEnvironment.ts";58import * as ServerEnvironment from "../environment/ServerEnvironment.ts";
59import * as ManagedEndpointRuntime from "./ManagedEndpointRuntime.ts";59import * as ManagedEndpointRuntime from "./ManagedEndpointRuntime.ts";
60import {60import {
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
2import * as nodePath from "node:path";
3import * as NodeServices from "@effect/platform-node/NodeServices";
4import { expect, it } from "@effect/vitest";
5import * as Effect from "effect/Effect";
6import * as Exit from "effect/Exit";
7import * as FileSystem from "effect/FileSystem";
8import * as Layer from "effect/Layer";
9import * as PlatformError from "effect/PlatformError";
10
11import * as ServerConfig from "../../config.ts";
12import * as ServerEnvironment from "../Services/ServerEnvironment.ts";
13import { ServerEnvironmentLive } from "./ServerEnvironment.ts";
14
15const makeServerEnvironmentLayer = (baseDir: string) =>
16 ServerEnvironmentLive.pipe(Layer.provide(ServerConfig.layerTest(process.cwd(), baseDir)));
17
18const 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
50it.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 @@
1import { EnvironmentId, type ExecutionEnvironmentDescriptor } from "@t3tools/contracts";
2import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess";
3import * as Crypto from "effect/Crypto";
4import * as Effect from "effect/Effect";
5import * as FileSystem from "effect/FileSystem";
6import * as Layer from "effect/Layer";
7import * as Path from "effect/Path";
8
9import { ServerConfig } from "../../config.ts";
10import { layer as ProcessRunnerLive } from "../../processRunner.ts";
11import { ServerEnvironment, type ServerEnvironmentShape } from "../Services/ServerEnvironment.ts";
12import packageJson from "../../../package.json" with { type: "json" };
13import { resolveServerEnvironmentLabel } from "./ServerEnvironmentLabel.ts";
14
15function 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
28function 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
41export 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
103export 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 @@
1import { afterEach, describe, expect, it } from "@effect/vitest";
2import * as Effect from "effect/Effect";
3import * as FileSystem from "effect/FileSystem";
4import * as Layer from "effect/Layer";
5import { HostProcessHostname, HostProcessPlatform } from "@t3tools/shared/hostProcess";
6import { vi } from "vite-plus/test";
7
8import * as ProcessRunner from "../../processRunner.ts";
9import { resolveServerEnvironmentLabel } from "./ServerEnvironmentLabel.ts";
10import { ChildProcessSpawner } from "effect/unstable/process";
11
12const runMock = vi.fn<ProcessRunner.ProcessRunner["Service"]["run"]>();
13
14const ProcessRunnerTest = Layer.succeed(
15 ProcessRunner.ProcessRunner,
16 ProcessRunner.ProcessRunner.of({
17 run: (input) => runMock(input),
18 }),
19);
20const NoopFileSystemLayer = FileSystem.layerNoop({});
21const TestLayer = Layer.merge(NoopFileSystemLayer, ProcessRunnerTest);
22const 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);
32const 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
43afterEach(() => {
44 runMock.mockReset();
45});
46
47describe("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 @@
1import { HostProcessHostname, HostProcessPlatform } from "@t3tools/shared/hostProcess";
2import * as Effect from "effect/Effect";
3import * as FileSystem from "effect/FileSystem";
4import * as Option from "effect/Option";
5
6import { ProcessRunner } from "../../processRunner.ts";
7
8interface ResolveServerEnvironmentLabelInput {
9 readonly cwdBaseName: string;
10}
11
12function normalizeLabel(value: string | null | undefined): string | null {
13 const trimmed = value?.trim();
14 return trimmed && trimmed.length > 0 ? trimmed : null;
15}
16
17function 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
35const 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
49const 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
69const 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
90export 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
2import { dirname } from "node:path";
3import * as NodeServices from "@effect/platform-node/NodeServices";
4import { expect, it } from "@effect/vitest";
5import * as Effect from "effect/Effect";
6import * as Exit from "effect/Exit";
7import * as FileSystem from "effect/FileSystem";
8import * as Layer from "effect/Layer";
9import * as PlatformError from "effect/PlatformError";
10
11import * as ServerConfig from "../config.ts";
12import * as ServerEnvironment from "./ServerEnvironment.ts";
13
14const makeServerEnvironmentLayer = (baseDir: string) =>
15 ServerEnvironment.layer.pipe(Layer.provide(ServerConfig.layerTest(process.cwd(), baseDir)));
16
17const 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
49it.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 @@
1import { EnvironmentId, type ExecutionEnvironmentDescriptor } from "@t3tools/contracts";
2import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess";
3import * as Context from "effect/Context";
4import * as Crypto from "effect/Crypto";
5import * as Effect from "effect/Effect";
6import * as FileSystem from "effect/FileSystem";
7import * as Layer from "effect/Layer";
8import * as Path from "effect/Path";
9
10import packageJson from "../../package.json" with { type: "json" };
11import * as ServerConfig from "../config.ts";
12import * as ProcessRunner from "../processRunner.ts";
13import { resolveServerEnvironmentLabel } from "./ServerEnvironmentLabel.ts";
14
15export 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
23function 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
36function 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
49export 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 */
114export const layer = Layer.effect(ServerEnvironment, make).pipe(Layer.provide(ProcessRunner.layer));
apps/server/src/environment/ServerEnvironmentLabel.test.ts created+175
...@@ -0,0 +1,175 @@
1import { afterEach, describe, expect, it } from "@effect/vitest";
2import * as Effect from "effect/Effect";
3import * as FileSystem from "effect/FileSystem";
4import * as Layer from "effect/Layer";
5import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner";
6import { HostProcessHostname, HostProcessPlatform } from "@t3tools/shared/hostProcess";
7import { vi } from "vite-plus/test";
8
9import * as ProcessRunner from "../processRunner.ts";
10import { resolveServerEnvironmentLabel } from "./ServerEnvironmentLabel.ts";
11
12const runMock = vi.fn<ProcessRunner.ProcessRunner["Service"]["run"]>();
13
14const ProcessRunnerTest = Layer.succeed(
15 ProcessRunner.ProcessRunner,
16 ProcessRunner.ProcessRunner.of({
17 run: (input) => runMock(input),
18 }),
19);
20const NoopFileSystemLayer = FileSystem.layerNoop({});
21const TestLayer = Layer.merge(NoopFileSystemLayer, ProcessRunnerTest);
22const 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);
32const 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
43afterEach(() => {
44 runMock.mockReset();
45});
46
47describe("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 @@
1import { HostProcessHostname, HostProcessPlatform } from "@t3tools/shared/hostProcess";
2import * as Effect from "effect/Effect";
3import * as FileSystem from "effect/FileSystem";
4import * as Option from "effect/Option";
5
6import * as ProcessRunner from "../processRunner.ts";
7
8interface ResolveServerEnvironmentLabelInput {
9 readonly cwdBaseName: string;
10}
11
12function normalizeLabel(value: string | null | undefined): string | null {
13 const trimmed = value?.trim();
14 return trimmed && trimmed.length > 0 ? trimmed : null;
15}
16
17function 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
35const 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
49const 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
69const 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
90export 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 @@
1import type { EnvironmentId, ExecutionEnvironmentDescriptor } from "@t3tools/contracts";
2import * as Context from "effect/Context";
3import type * as Effect from "effect/Effect";
4
5export interface ServerEnvironmentShape {
6 readonly getEnvironmentId: Effect.Effect<EnvironmentId>;
7 readonly getDescriptor: Effect.Effect<ExecutionEnvironmentDescriptor>;
8}
9
10export 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";
2121
22import { GitCommandError, TextGenerationError } from "@t3tools/contracts";22import { GitCommandError, TextGenerationError } from "@t3tools/contracts";
23import * as GitManager from "./GitManager.ts";
24import * as GitHubCli from "../sourceControl/GitHubCli.ts";23import * as GitHubCli from "../sourceControl/GitHubCli.ts";
25import * as TextGeneration from "../textGeneration/TextGeneration.ts";24import * as TextGeneration from "../textGeneration/TextGeneration.ts";
26import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";25import * 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";
28import * as GitHubSourceControlProvider from "../sourceControl/GitHubSourceControlProvider.ts";27import * as GitHubSourceControlProvider from "../sourceControl/GitHubSourceControlProvider.ts";
29import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts";28import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts";
30import * as ServerConfig from "../config.ts";29import * as ServerConfig from "../config.ts";
30import * as ProjectSetupScriptRunner from "../project/ProjectSetupScriptRunner.ts";
31import * as ServerSettings from "../serverSettings.ts";31import * as ServerSettings from "../serverSettings.ts";
32import * as ProjectSetupScriptRunner from "../project/Services/ProjectSetupScriptRunner.ts";32import * as GitManager from "./GitManager.ts";
3333
34interface FakeGhScenario {34interface 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 {
4343
44import { GitManagerError } from "@t3tools/contracts";44import { GitManagerError } from "@t3tools/contracts";
45import * as TextGeneration from "../textGeneration/TextGeneration.ts";45import * as TextGeneration from "../textGeneration/TextGeneration.ts";
46import * as ProjectSetupScriptRunner from "../project/Services/ProjectSetupScriptRunner.ts";46import * as ProjectSetupScriptRunner from "../project/ProjectSetupScriptRunner.ts";
47import { extractBranchNameFromRemoteRef } from "./remoteRefs.ts";47import { extractBranchNameFromRemoteRef } from "./remoteRefs.ts";
48import * as ServerSettings from "../serverSettings.ts";48import * as ServerSettings from "../serverSettings.ts";
49import type { GitManagerServiceError } from "@t3tools/contracts";49import 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";
42import * as ServerEnvironment from "./environment/Services/ServerEnvironment.ts";42import * as ServerEnvironment from "./environment/ServerEnvironment.ts";
43import { browserApiCorsAllowedHeaders, browserApiCorsAllowedMethods } from "./httpCors.ts";43import { browserApiCorsAllowedHeaders, browserApiCorsAllowedMethods } from "./httpCors.ts";
4444
45const OTLP_TRACES_PROXY_PATH = "/api/observability/v1/traces";45const 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"
4import * as Effect from "effect/Effect";4import * as Effect from "effect/Effect";
5import { HttpServer } from "effect/unstable/http";5import { HttpServer } from "effect/unstable/http";
66
7import { ServerEnvironment } from "../environment/Services/ServerEnvironment.ts";7import * as ServerEnvironment from "../environment/ServerEnvironment.ts";
8import * as McpSessionRegistry from "./McpSessionRegistry.ts";8import * as McpSessionRegistry from "./McpSessionRegistry.ts";
99
10const environmentId = EnvironmentId.make("environment-1");10const 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 });
16const fakeHttpServer = makeFakeHttpServer("127.0.0.1");16const fakeHttpServer = makeFakeHttpServer("127.0.0.1");
17const fakeEnvironment = ServerEnvironment.of({17const 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 );
3434
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";
7import * as SynchronizedRef from "effect/SynchronizedRef";7import * as SynchronizedRef from "effect/SynchronizedRef";
8import { HttpServer } from "effect/unstable/http";8import { HttpServer } from "effect/unstable/http";
99
10import { ServerEnvironment } from "../environment/Services/ServerEnvironment.ts";10import * as ServerEnvironment from "../environment/ServerEnvironment.ts";
11import * as McpInvocationContext from "./McpInvocationContext.ts";11import * as McpInvocationContext from "./McpInvocationContext.ts";
12import * as McpProviderSession from "./McpProviderSession.ts";12import * as McpProviderSession from "./McpProviderSession.ts";
1313
...@@ -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(
194export const layer: Layer.Layer<194export const layer: Layer.Layer<
195 McpSessionRegistry,195 McpSessionRegistry,
196 never,196 never,
197 Crypto.Crypto | ServerEnvironment | HttpServer.HttpServer197 Crypto.Crypto | ServerEnvironment.ServerEnvironment | HttpServer.HttpServer
198> = Layer.effect(McpSessionRegistry, make);198> = Layer.effect(McpSessionRegistry, make);
199199
200export const issueActiveMcpCredential = (200export 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";
34import * as VcsDriverRegistry from "../../vcs/VcsDriverRegistry.ts";34import * as VcsDriverRegistry from "../../vcs/VcsDriverRegistry.ts";
35import * as VcsProcess from "../../vcs/VcsProcess.ts";35import * as VcsProcess from "../../vcs/VcsProcess.ts";
36import { VcsStatusBroadcaster } from "../../vcs/VcsStatusBroadcaster.ts";36import { VcsStatusBroadcaster } from "../../vcs/VcsStatusBroadcaster.ts";
37import { RepositoryIdentityResolverLive } from "../../project/Layers/RepositoryIdentityResolver.ts";37import * as RepositoryIdentityResolver from "../../project/RepositoryIdentityResolver.ts";
38import { CheckpointReactorLive } from "./CheckpointReactor.ts";38import { CheckpointReactorLive } from "./CheckpointReactor.ts";
39import { OrchestrationEngineLive } from "./OrchestrationEngine.ts";39import { OrchestrationEngineLive } from "./OrchestrationEngine.ts";
40import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts";40import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts";
...@@ -56,7 +56,7 @@ import {...@@ -56,7 +56,7 @@ import {
56import { checkpointRefForThreadTurn } from "../../checkpointing/Utils.ts";56import { checkpointRefForThreadTurn } from "../../checkpointing/Utils.ts";
57import { ServerConfig } from "../../config.ts";57import { ServerConfig } from "../../config.ts";
58import * as WorkspaceEntries from "../../workspace/WorkspaceEntries.ts";58import * as WorkspaceEntries from "../../workspace/WorkspaceEntries.ts";
59import { WorkspacePathsLive } from "../../workspace/Layers/WorkspacePaths.ts";59import * as WorkspacePaths from "../../workspace/WorkspacePaths.ts";
6060
61const asProjectId = (value: string): ProjectId => ProjectId.make(value);61const asProjectId = (value: string): ProjectId => ProjectId.make(value);
62const asTurnId = (value: string): TurnId => TurnId.make(value);62const 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 );
304304
...@@ -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";
30import { RepositoryIdentityResolverLive } from "../../project/Layers/RepositoryIdentityResolver.ts";30import * as RepositoryIdentityResolver from "../../project/RepositoryIdentityResolver.ts";
31import { OrchestrationEngineLive } from "./OrchestrationEngine.ts";31import { OrchestrationEngineLive } from "./OrchestrationEngine.ts";
32import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts";32import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts";
33import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts";33import { 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";
26import { OrchestrationEventStore } from "../../persistence/Services/OrchestrationEventStore.ts";26import { OrchestrationEventStore } from "../../persistence/Services/OrchestrationEventStore.ts";
27import { RepositoryIdentityResolverLive } from "../../project/Layers/RepositoryIdentityResolver.ts";27import * as RepositoryIdentityResolver from "../../project/RepositoryIdentityResolver.ts";
28import { OrchestrationEngineLive } from "./OrchestrationEngine.ts";28import { OrchestrationEngineLive } from "./OrchestrationEngine.ts";
29import {29import {
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";
14import * as SqlClient from "effect/unstable/sql/SqlClient";14import * as SqlClient from "effect/unstable/sql/SqlClient";
1515
16import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts";16import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts";
17import { RepositoryIdentityResolver } from "../../project/Services/RepositoryIdentityResolver.ts";17import * as RepositoryIdentityResolver from "../../project/RepositoryIdentityResolver.ts";
18import { RepositoryIdentityResolverLive } from "../../project/Layers/RepositoryIdentityResolver.ts";
19import { ORCHESTRATION_PROJECTOR_NAMES } from "./ProjectionPipeline.ts";18import { ORCHESTRATION_PROJECTOR_NAMES } from "./ProjectionPipeline.ts";
20import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts";19import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts";
21import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts";20import { 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
2827
29const projectionSnapshotLayer = it.layer(28const 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
48import { ProjectionThreadProposedPlan } from "../../persistence/Services/ProjectionThreadProposedPlans.ts";48import { ProjectionThreadProposedPlan } from "../../persistence/Services/ProjectionThreadProposedPlans.ts";
49import { ProjectionThreadSession } from "../../persistence/Services/ProjectionThreadSessions.ts";49import { ProjectionThreadSession } from "../../persistence/Services/ProjectionThreadSessions.ts";
50import { ProjectionThread } from "../../persistence/Services/ProjectionThreads.ts";50import { ProjectionThread } from "../../persistence/Services/ProjectionThreads.ts";
51import { RepositoryIdentityResolver } from "../../project/Services/RepositoryIdentityResolver.ts";51import * as RepositoryIdentityResolver from "../../project/RepositoryIdentityResolver.ts";
52import { ORCHESTRATION_PROJECTOR_NAMES } from "./ProjectionPipeline.ts";52import { ORCHESTRATION_PROJECTOR_NAMES } from "./ProjectionPipeline.ts";
53import {53import {
54 ProjectionSnapshotQuery,54 ProjectionSnapshotQuery,
...@@ -262,7 +262,7 @@ function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: st...@@ -262,7 +262,7 @@ function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: st
262262
263const makeProjectionSnapshotQuery = Effect.gen(function* () {263const 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";
44import { makeProviderRegistryLayer } from "../../provider/testUtils/providerRegistryMock.ts";44import { makeProviderRegistryLayer } from "../../provider/testUtils/providerRegistryMock.ts";
45import { TextGeneration, type TextGenerationShape } from "../../textGeneration/TextGeneration.ts";45import { TextGeneration, type TextGenerationShape } from "../../textGeneration/TextGeneration.ts";
46import { RepositoryIdentityResolverLive } from "../../project/Layers/RepositoryIdentityResolver.ts";46import * as RepositoryIdentityResolver from "../../project/RepositoryIdentityResolver.ts";
47import { OrchestrationEngineLive } from "./OrchestrationEngine.ts";47import { OrchestrationEngineLive } from "./OrchestrationEngine.ts";
48import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts";48import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts";
49import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts";49import { 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";
42import { RepositoryIdentityResolverLive } from "../../project/Layers/RepositoryIdentityResolver.ts";42import * as RepositoryIdentityResolver from "../../project/RepositoryIdentityResolver.ts";
43import { OrchestrationEngineLive } from "./OrchestrationEngine.ts";43import { OrchestrationEngineLive } from "./OrchestrationEngine.ts";
44import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts";44import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts";
45import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts";45import { 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 {
11import { createAttachmentId, resolveAttachmentPath } from "../attachmentStore.ts";11import { createAttachmentId, resolveAttachmentPath } from "../attachmentStore.ts";
12import { ServerConfig } from "../config.ts";12import { ServerConfig } from "../config.ts";
13import { parseBase64DataUrl } from "../imageMime.ts";13import { parseBase64DataUrl } from "../imageMime.ts";
14import { WorkspacePaths } from "../workspace/Services/WorkspacePaths.ts";14import * as WorkspacePaths from "../workspace/WorkspacePaths.ts";
1515
16export const normalizeDispatchCommand = (command: ClientOrchestrationCommand) =>16export 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;
2222
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 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { it, describe, expect } from "@effect/vitest";
3import * as Effect from "effect/Effect";
4import * as FileSystem from "effect/FileSystem";
5import * as Layer from "effect/Layer";
6import * as Path from "effect/Path";
7
8import { ProjectFaviconResolver } from "../Services/ProjectFaviconResolver.ts";
9import { ProjectFaviconResolverLive } from "./ProjectFaviconResolver.ts";
10import { WorkspacePathsLive } from "../../workspace/Layers/WorkspacePaths.ts";
11
12const TestLayer = Layer.empty.pipe(
13 Layer.provideMerge(ProjectFaviconResolverLive.pipe(Layer.provide(WorkspacePathsLive))),
14 Layer.provideMerge(NodeServices.layer),
15);
16
17const makeTempDir = Effect.gen(function* () {
18 const fileSystem = yield* FileSystem.FileSystem;
19 return yield* fileSystem.makeTempDirectoryScoped({
20 prefix: "t3code-project-favicon-",
21 });
22});
23
24const 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
38it.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 @@
1import * as Effect from "effect/Effect";
2import * as FileSystem from "effect/FileSystem";
3import * as Layer from "effect/Layer";
4import * as Path from "effect/Path";
5
6import {
7 ProjectFaviconResolver,
8 type ProjectFaviconResolverShape,
9} from "../Services/ProjectFaviconResolver.ts";
10import { WorkspacePaths } from "../../workspace/Services/WorkspacePaths.ts";
11
12// Well-known favicon paths checked in order.
13const 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.
38const 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.
49const LINK_ICON_HTML_RE =
50 /<link\b(?=[^>]*\brel=["'](?:icon|shortcut icon)["'])(?=[^>]*\bhref=["']([^"'?]+))[^>]*>/i;
51const LINK_ICON_OBJ_RE =
52 /(?=[^}]*\brel\s*:\s*["'](?:icon|shortcut icon)["'])(?=[^}]*\bhref\s*:\s*["']([^"'?]+))[^}]*/i;
53
54function 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
62export 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
146export const ProjectFaviconResolverLive = Layer.effect(
147 ProjectFaviconResolver,
148 makeProjectFaviconResolver,
149);
apps/server/src/project/Layers/ProjectSetupScriptRunner.test.ts deleted-165
...@@ -1,165 +0,0 @@
1import { ProjectId, type OrchestrationProject } from "@t3tools/contracts";
2import * as Effect from "effect/Effect";
3import * as Layer from "effect/Layer";
4import * as Option from "effect/Option";
5import { describe, expect, it, vi } from "vite-plus/test";
6
7import { ProjectionSnapshotQuery } from "../../orchestration/Services/ProjectionSnapshotQuery.ts";
8import * as TerminalManager from "../../terminal/Manager.ts";
9import { ProjectSetupScriptRunner } from "../Services/ProjectSetupScriptRunner.ts";
10import { ProjectSetupScriptRunnerLive } from "./ProjectSetupScriptRunner.ts";
11
12const 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
23const 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
44describe("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 @@
1import { ProjectId } from "@t3tools/contracts";
2import { projectScriptRuntimeEnv, setupProjectScript } from "@t3tools/shared/projectScripts";
3import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";
5import * as Option from "effect/Option";
6
7import { ProjectionSnapshotQuery } from "../../orchestration/Services/ProjectionSnapshotQuery.ts";
8import * as TerminalManager from "../../terminal/Manager.ts";
9import {
10 type ProjectSetupScriptRunnerShape,
11 ProjectSetupScriptRunner,
12 ProjectSetupScriptRunnerError,
13} from "../Services/ProjectSetupScriptRunner.ts";
14
15const 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
100export const ProjectSetupScriptRunnerLive = Layer.effect(
101 ProjectSetupScriptRunner,
102 makeProjectSetupScriptRunner,
103);
apps/server/src/project/Layers/RepositoryIdentityResolver.test.ts deleted-239
...@@ -1,239 +0,0 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { expect, it } from "@effect/vitest";
3import * as Duration from "effect/Duration";
4import * as Effect from "effect/Effect";
5import * as FileSystem from "effect/FileSystem";
6import * as Layer from "effect/Layer";
7import * as Path from "effect/Path";
8import { TestClock } from "effect/testing";
9
10import * as ProcessRunner from "../../processRunner.ts";
11import { RepositoryIdentityResolver } from "../Services/RepositoryIdentityResolver.ts";
12import {
13 makeRepositoryIdentityResolver,
14 RepositoryIdentityResolverLive,
15} from "./RepositoryIdentityResolver.ts";
16
17const normalizePathSeparators = (value: string) => value.replaceAll("\\", "/");
18const normalizeResolvedPath = (value: string) => normalizePathSeparators(value);
19
20const 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
29const 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
41it.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 @@
1import type { RepositoryIdentity } from "@t3tools/contracts";
2import * as Cache from "effect/Cache";
3import * as Duration from "effect/Duration";
4import * as Effect from "effect/Effect";
5import * as Exit from "effect/Exit";
6import * as Layer from "effect/Layer";
7import {
8 detectSourceControlProviderFromGitRemoteUrl,
9 normalizeGitRemoteUrl,
10} from "@t3tools/shared/git";
11
12import * as ProcessRunner from "../../processRunner.ts";
13import {
14 RepositoryIdentityResolver,
15 type RepositoryIdentityResolverShape,
16} from "../Services/RepositoryIdentityResolver.ts";
17
18function 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
34function 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
49function 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
76const DEFAULT_REPOSITORY_IDENTITY_CACHE_CAPACITY = 512;
77const DEFAULT_POSITIVE_CACHE_TTL = Duration.minutes(1);
78const DEFAULT_NEGATIVE_CACHE_TTL = Duration.minutes(1);
79
80interface RepositoryIdentityResolverOptions {
81 readonly cacheCapacity?: number;
82 readonly positiveCacheTtl?: Duration.Input;
83 readonly negativeCacheTtl?: Duration.Input;
84}
85
86const 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
113const 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
134export 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
170export 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 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { it, describe, expect } from "@effect/vitest";
3import * as Effect from "effect/Effect";
4import * as FileSystem from "effect/FileSystem";
5import * as Layer from "effect/Layer";
6import * as Path from "effect/Path";
7
8import * as WorkspacePaths from "../workspace/WorkspacePaths.ts";
9import * as ProjectFaviconResolver from "./ProjectFaviconResolver.ts";
10
11const TestLayer = Layer.empty.pipe(
12 Layer.provideMerge(ProjectFaviconResolver.layer.pipe(Layer.provide(WorkspacePaths.layer))),
13 Layer.provideMerge(NodeServices.layer),
14);
15
16const makeTempDir = Effect.gen(function* () {
17 const fileSystem = yield* FileSystem.FileSystem;
18 return yield* fileSystem.makeTempDirectoryScoped({
19 prefix: "t3code-project-favicon-",
20 });
21});
22
23const 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
37it.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 */
9import * as Context from "effect/Context";
10import * as Effect from "effect/Effect";
11import * as FileSystem from "effect/FileSystem";
12import * as Layer from "effect/Layer";
13import * as Path from "effect/Path";
14
15import * as WorkspacePaths from "../workspace/WorkspacePaths.ts";
16
17// Well-known favicon paths checked in order.
18const 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.
43const 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.
54const LINK_ICON_HTML_RE =
55 /<link\b(?=[^>]*\brel=["'](?:icon|shortcut icon)["'])(?=[^>]*\bhref=["']([^"'?]+))[^>]*>/i;
56const LINK_ICON_OBJ_RE =
57 /(?=[^}]*\brel\s*:\s*["'](?:icon|shortcut icon)["'])(?=[^}]*\bhref\s*:\s*["']([^"'?]+))[^}]*/i;
58
59/** Service tag for project favicon resolution. */
60export 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
72function 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
80export 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
162export const layer = Layer.effect(ProjectFaviconResolver, make);
apps/server/src/project/ProjectSetupScriptRunner.test.ts created+148
...@@ -0,0 +1,148 @@
1import { describe, expect, it, vi } from "@effect/vitest";
2import { type OrchestrationProject, ProjectId } from "@t3tools/contracts";
3import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";
5import * as Option from "effect/Option";
6
7import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts";
8import * as TerminalManager from "../terminal/Manager.ts";
9import * as ProjectSetupScriptRunner from "./ProjectSetupScriptRunner.ts";
10
11const 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
22const 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
43const 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
57const 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
66describe("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 @@
1import { ProjectId } from "@t3tools/contracts";
2import { projectScriptRuntimeEnv, setupProjectScript } from "@t3tools/shared/projectScripts";
3import * as Context from "effect/Context";
4import * as Effect from "effect/Effect";
5import * as Layer from "effect/Layer";
6import * as Option from "effect/Option";
7import * as Schema from "effect/Schema";
8
9import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts";
10import * as TerminalManager from "../terminal/Manager.ts";
11
12export interface ProjectSetupScriptRunnerResultNoScript {
13 readonly status: "no-script";
14}
15
16export interface ProjectSetupScriptRunnerResultStarted {
17 readonly status: "started";
18 readonly scriptId: string;
19 readonly scriptName: string;
20 readonly terminalId: string;
21 readonly cwd: string;
22}
23
24export type ProjectSetupScriptRunnerResult =
25 | ProjectSetupScriptRunnerResultNoScript
26 | ProjectSetupScriptRunnerResultStarted;
27
28export interface ProjectSetupScriptRunnerInput {
29 readonly threadId: string;
30 readonly projectId?: string;
31 readonly projectCwd?: string;
32 readonly worktreePath: string;
33 readonly preferredTerminalId?: string;
34}
35
36export 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
52export 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
66export const ProjectSetupScriptRunnerError = Schema.Union([
67 ProjectSetupScriptOperationError,
68 ProjectSetupScriptProjectNotFoundError,
69]);
70export type ProjectSetupScriptRunnerError = typeof ProjectSetupScriptRunnerError.Type;
71
72export 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
81const isProjectSetupScriptRunnerError = Schema.is(ProjectSetupScriptRunnerError);
82
83function 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
98function 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
107export 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
179export const layer = Layer.effect(ProjectSetupScriptRunner, make);
apps/server/src/project/RepositoryIdentityResolver.test.ts created+235
...@@ -0,0 +1,235 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { expect, it } from "@effect/vitest";
3import * as Duration from "effect/Duration";
4import * as Effect from "effect/Effect";
5import * as FileSystem from "effect/FileSystem";
6import * as Layer from "effect/Layer";
7import * as Path from "effect/Path";
8import { TestClock } from "effect/testing";
9
10import * as ProcessRunner from "../processRunner.ts";
11import * as RepositoryIdentityResolver from "./RepositoryIdentityResolver.ts";
12
13const normalizePathSeparators = (value: string) => value.replaceAll("\\", "/");
14const normalizeResolvedPath = (value: string) => normalizePathSeparators(value);
15
16const 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
25const 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
37it.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 @@
1import type { RepositoryIdentity } from "@t3tools/contracts";
2import {
3 detectSourceControlProviderFromGitRemoteUrl,
4 normalizeGitRemoteUrl,
5} from "@t3tools/shared/git";
6import * as Cache from "effect/Cache";
7import * as Context from "effect/Context";
8import * as Duration from "effect/Duration";
9import * as Effect from "effect/Effect";
10import * as Exit from "effect/Exit";
11import * as Layer from "effect/Layer";
12
13import * as ProcessRunner from "../processRunner.ts";
14
15const DEFAULT_REPOSITORY_IDENTITY_CACHE_CAPACITY = 512;
16const DEFAULT_POSITIVE_CACHE_TTL = Duration.minutes(1);
17const DEFAULT_NEGATIVE_CACHE_TTL = Duration.minutes(1);
18
19export interface RepositoryIdentityResolverOptions {
20 readonly cacheCapacity?: number;
21 readonly positiveCacheTtl?: Duration.Input;
22 readonly negativeCacheTtl?: Duration.Input;
23}
24
25export class RepositoryIdentityResolver extends Context.Service<
26 RepositoryIdentityResolver,
27 {
28 readonly resolve: (cwd: string) => Effect.Effect<RepositoryIdentity | null>;
29 }
30>()("t3/project/RepositoryIdentityResolver") {}
31
32function 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
48function 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
63function 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
90const 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
117const 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
138export 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
172export 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 */
9import * as Context from "effect/Context";
10import type * as Effect from "effect/Effect";
11
12/**
13 * ProjectFaviconResolverShape - Service API for project favicon lookup.
14 */
15export 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 */
27export 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 @@
1import * as Context from "effect/Context";
2import * as Data from "effect/Data";
3import type * as Effect from "effect/Effect";
4
5export interface ProjectSetupScriptRunnerResultNoScript {
6 readonly status: "no-script";
7}
8
9export interface ProjectSetupScriptRunnerResultStarted {
10 readonly status: "started";
11 readonly scriptId: string;
12 readonly scriptName: string;
13 readonly terminalId: string;
14 readonly cwd: string;
15}
16
17export type ProjectSetupScriptRunnerResult =
18 | ProjectSetupScriptRunnerResultNoScript
19 | ProjectSetupScriptRunnerResultStarted;
20
21export interface ProjectSetupScriptRunnerInput {
22 readonly threadId: string;
23 readonly projectId?: string;
24 readonly projectCwd?: string;
25 readonly worktreePath: string;
26 readonly preferredTerminalId?: string;
27}
28
29export class ProjectSetupScriptRunnerError extends Data.TaggedError(
30 "ProjectSetupScriptRunnerError",
31)<{
32 readonly message: string;
33}> {}
34
35export interface ProjectSetupScriptRunnerShape {
36 readonly runForThread: (
37 input: ProjectSetupScriptRunnerInput,
38 ) => Effect.Effect<ProjectSetupScriptRunnerResult, ProjectSetupScriptRunnerError>;
39}
40
41export 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 @@
1import type { RepositoryIdentity } from "@t3tools/contracts";
2import * as Context from "effect/Context";
3import type * as Effect from "effect/Effect";
4
5export interface RepositoryIdentityResolverShape {
6 readonly resolve: (cwd: string) => Effect.Effect<RepositoryIdentity | null>;
7}
8
9export 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 @@
1import * as NodeCrypto from "node:crypto";1import { generateKeyPairSync } from "node:crypto";
2import * as NodeServices from "@effect/platform-node/NodeServices";2import * as NodeServices from "@effect/platform-node/NodeServices";
33
4import type {4import type {
...@@ -29,7 +29,7 @@ import * as Stream from "effect/Stream";...@@ -29,7 +29,7 @@ import * as Stream from "effect/Stream";
29import * as Tracer from "effect/Tracer";29import * as Tracer from "effect/Tracer";
3030
31import * as ServerSecretStore from "../auth/ServerSecretStore.ts";31import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
32import { ServerEnvironment } from "../environment/Services/ServerEnvironment.ts";32import * as ServerEnvironment from "../environment/ServerEnvironment.ts";
33import {33import {
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 });
349349
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", () => {
494494
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", () => {
642642
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";
43import { getOrCreateEnvironmentKeyPairFromSecretStore } from "../cloud/environmentKeys.ts";43import { getOrCreateEnvironmentKeyPairFromSecretStore } from "../cloud/environmentKeys.ts";
44import * as ServerEnvironment from "../environment/Services/ServerEnvironment.ts";44import * as ServerEnvironment from "../environment/ServerEnvironment.ts";
45import * as OrchestrationEngine from "../orchestration/Services/OrchestrationEngine.ts";45import * as OrchestrationEngine from "../orchestration/Services/OrchestrationEngine.ts";
46import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts";46import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts";
4747
apps/server/src/server.test.ts+42-41
...@@ -1,7 +1,7 @@...@@ -1,7 +1,7 @@
1import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer";1import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer";
2import * as NodeSocket from "@effect/platform-node/NodeSocket";2import * as NodeSocket from "@effect/platform-node/NodeSocket";
3import * as NodeServices from "@effect/platform-node/NodeServices";3import * as NodeServices from "@effect/platform-node/NodeServices";
4import * as NodeCrypto from "node:crypto";4import { generateKeyPairSync, type KeyObject, sign } from "node:crypto";
55
6import {6import {
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";
89import * as PreviewManager from "./preview/Manager.ts";89import * as PreviewManager from "./preview/Manager.ts";
90import * as PortScanner from "./preview/PortScanner.ts";90import * as PortScanner from "./preview/PortScanner.ts";
91import * as BrowserTraceCollector from "./observability/BrowserTraceCollector.ts";91import * as BrowserTraceCollector from "./observability/BrowserTraceCollector.ts";
92import { ProjectFaviconResolverLive } from "./project/Layers/ProjectFaviconResolver.ts";92import * as ProjectFaviconResolver from "./project/ProjectFaviconResolver.ts";
93import * as ProjectSetupScriptRunner from "./project/Services/ProjectSetupScriptRunner.ts";93import * as ProjectSetupScriptRunner from "./project/ProjectSetupScriptRunner.ts";
94import * as RepositoryIdentityResolver from "./project/Services/RepositoryIdentityResolver.ts";94import * as RepositoryIdentityResolver from "./project/RepositoryIdentityResolver.ts";
95import * as ServerEnvironment from "./environment/Services/ServerEnvironment.ts";95import * as ServerEnvironment from "./environment/ServerEnvironment.ts";
96import * as WorkspaceEntries from "./workspace/WorkspaceEntries.ts";96import * as WorkspaceEntries from "./workspace/WorkspaceEntries.ts";
97import { WorkspaceFileSystemLive } from "./workspace/Layers/WorkspaceFileSystem.ts";97import * as WorkspaceFileSystem from "./workspace/WorkspaceFileSystem.ts";
98import { WorkspacePathsLive } from "./workspace/Layers/WorkspacePaths.ts";98import * as WorkspacePaths from "./workspace/WorkspacePaths.ts";
99import * as GitVcsDriver from "./vcs/GitVcsDriver.ts";99import * as GitVcsDriver from "./vcs/GitVcsDriver.ts";
100import * as VcsDriver from "./vcs/VcsDriver.ts";100import * as VcsDriver from "./vcs/VcsDriver.ts";
101import * as VcsStatusBroadcaster from "./vcs/VcsStatusBroadcaster.ts";101import * 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.publicJwk957 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};
10301030
...@@ -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};
10631063
...@@ -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();
20562056
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();
21332133
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();
21762176
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 });
22702270
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();
23472347
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();
24062406
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();
24652465
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();
25252525
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();
25862586
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 });
26662666
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();
27352735
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();
28022802
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();
28532853
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();
29042904
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();
29692969
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();
30193019
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) => {
44474447
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 );
44564453
...@@ -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";
52import { hasCloudPublicConfig } from "./cloud/publicConfig.ts";52import { hasCloudPublicConfig } from "./cloud/publicConfig.ts";
53import { ProviderRegistryLive } from "./provider/Layers/ProviderRegistry.ts";53import { ProviderRegistryLive } from "./provider/Layers/ProviderRegistry.ts";
54import * as ServerSettings from "./serverSettings.ts";54import * as ServerSettings from "./serverSettings.ts";
55import { ProjectFaviconResolverLive } from "./project/Layers/ProjectFaviconResolver.ts";55import * as ProjectFaviconResolver from "./project/ProjectFaviconResolver.ts";
56import { RepositoryIdentityResolverLive } from "./project/Layers/RepositoryIdentityResolver.ts";56import * as RepositoryIdentityResolver from "./project/RepositoryIdentityResolver.ts";
57import * as WorkspaceEntries from "./workspace/WorkspaceEntries.ts";57import * as WorkspaceEntries from "./workspace/WorkspaceEntries.ts";
58import { WorkspaceFileSystemLive } from "./workspace/Layers/WorkspaceFileSystem.ts";58import * as WorkspaceFileSystem from "./workspace/WorkspaceFileSystem.ts";
59import { WorkspacePathsLive } from "./workspace/Layers/WorkspacePaths.ts";59import * as WorkspacePaths from "./workspace/WorkspacePaths.ts";
60import * as GitVcsDriver from "./vcs/GitVcsDriver.ts";60import * as GitVcsDriver from "./vcs/GitVcsDriver.ts";
61import * as VcsDriverRegistry from "./vcs/VcsDriverRegistry.ts";61import * as VcsDriverRegistry from "./vcs/VcsDriverRegistry.ts";
62import * as VcsProjectConfig from "./vcs/VcsProjectConfig.ts";62import * 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";
67import * as ReviewService from "./review/ReviewService.ts";67import * as ReviewService from "./review/ReviewService.ts";
68import * as SourceControlProviderRegistry from "./sourceControl/SourceControlProviderRegistry.ts";68import * as SourceControlProviderRegistry from "./sourceControl/SourceControlProviderRegistry.ts";
69import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts";69import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts";
70import { ProjectSetupScriptRunnerLive } from "./project/Layers/ProjectSetupScriptRunner.ts";70import * as ProjectSetupScriptRunner from "./project/ProjectSetupScriptRunner.ts";
71import { ObservabilityLive } from "./observability/Layers/Observability.ts";71import { ObservabilityLive } from "./observability/Layers/Observability.ts";
72import { ServerEnvironmentLive } from "./environment/Layers/ServerEnvironment.ts";72import * as ServerEnvironment from "./environment/ServerEnvironment.ts";
73import { authHttpApiLayer, environmentAuthenticatedAuthLayer } from "./auth/http.ts";73import { authHttpApiLayer, environmentAuthenticatedAuthLayer } from "./auth/http.ts";
74import * as ServerSecretStore from "./auth/ServerSecretStore.ts";74import * as ServerSecretStore from "./auth/ServerSecretStore.ts";
75import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts";75import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts";
...@@ -195,7 +195,7 @@ const SourceControlProviderRegistryLayerLive = SourceControlProviderRegistry.lay...@@ -195,7 +195,7 @@ const SourceControlProviderRegistryLayerLive = SourceControlProviderRegistry.lay
195);195);
196196
197const GitManagerLayerLive = GitManager.layer.pipe(197const 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);
250250
251const WorkspaceEntriesLayerLive = WorkspaceEntries.layer.pipe(Layer.provide(WorkspacePathsLive));251const WorkspaceEntriesLayerLive = WorkspaceEntries.layer.pipe(Layer.provide(WorkspacePaths.layer));
252252
253const WorkspaceFileSystemLayerLive = WorkspaceFileSystemLive.pipe(253const WorkspaceFileSystemLayerLive = WorkspaceFileSystem.layer.pipe(
254 Layer.provide(WorkspacePathsLive),254 Layer.provide(WorkspacePaths.layer),
255 Layer.provide(WorkspaceEntriesLayerLive),255 Layer.provide(WorkspaceEntriesLayerLive),
256);256);
257257
258const WorkspaceLayerLive = Layer.mergeAll(258const WorkspaceLayerLive = Layer.mergeAll(
259 WorkspacePathsLive,259 WorkspacePaths.layer,
260 WorkspaceEntriesLayerLive,260 WorkspaceEntriesLayerLive,
261 WorkspaceFileSystemLayerLive,261 WorkspaceFileSystemLayerLive,
262);262);
263263
264const ProjectFaviconResolverLayerLive = ProjectFaviconResolverLive.pipe(264const ProjectFaviconResolverLayerLive = ProjectFaviconResolver.layer.pipe(
265 Layer.provide(WorkspacePathsLive),265 Layer.provide(WorkspacePaths.layer),
266);266);
267267
268const AuthLayerLive = EnvironmentAuth.layer.pipe(268const 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", () =>
5757
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 );
6366
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
30import * as OrchestrationReactor from "./orchestration/Services/OrchestrationReactor.ts";30import * as OrchestrationReactor from "./orchestration/Services/OrchestrationReactor.ts";
31import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts";31import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts";
32import * as ServerSettings from "./serverSettings.ts";32import * as ServerSettings from "./serverSettings.ts";
33import * as ServerEnvironment from "./environment/Services/ServerEnvironment.ts";
34import * as AnalyticsService from "./telemetry/AnalyticsService.ts";33import * as AnalyticsService from "./telemetry/AnalyticsService.ts";
34import * as ServerEnvironment from "./environment/ServerEnvironment.ts";
35import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts";35import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts";
36import * as ProviderSessionReaper from "./provider/Services/ProviderSessionReaper.ts";36import * as ProviderSessionReaper from "./provider/Services/ProviderSessionReaper.ts";
37import {37import {
...@@ -44,15 +44,14 @@ import {...@@ -44,15 +44,14 @@ import {
44export class ServerRuntimeStartupError extends Schema.TaggedErrorClass<ServerRuntimeStartupError>()(44export 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}
5857
...@@ -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 );
291290
292const make = Effect.gen(function* () {291export 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 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { it, describe, expect } from "@effect/vitest";
3import * as Effect from "effect/Effect";
4import * as FileSystem from "effect/FileSystem";
5import * as Layer from "effect/Layer";
6import * as Path from "effect/Path";
7
8import { ServerConfig } from "../../config.ts";
9import * as VcsDriverRegistry from "../../vcs/VcsDriverRegistry.ts";
10import * as VcsProcess from "../../vcs/VcsProcess.ts";
11import * as WorkspaceEntries from "../WorkspaceEntries.ts";
12import { WorkspaceFileSystem } from "../Services/WorkspaceFileSystem.ts";
13import { WorkspaceFileSystemLive } from "./WorkspaceFileSystem.ts";
14import { WorkspacePathsLive } from "./WorkspacePaths.ts";
15
16const ProjectLayer = WorkspaceFileSystemLive.pipe(
17 Layer.provide(WorkspacePathsLive),
18 Layer.provide(WorkspaceEntries.layer.pipe(Layer.provide(WorkspacePathsLive))),
19);
20
21const 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
34const makeTempDir = Effect.gen(function* () {
35 const fileSystem = yield* FileSystem.FileSystem;
36 return yield* fileSystem.makeTempDirectoryScoped({
37 prefix: "t3code-workspace-files-",
38 });
39});
40
41const 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
55it.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
2import fsPromises from "node:fs/promises";
3
4import * as Effect from "effect/Effect";
5import * as FileSystem from "effect/FileSystem";
6import * as Layer from "effect/Layer";
7import * as Path from "effect/Path";
8
9import {
10 WorkspaceFileSystem,
11 WorkspaceFileSystemError,
12 type WorkspaceFileSystemShape,
13} from "../Services/WorkspaceFileSystem.ts";
14import * as WorkspaceEntries from "../WorkspaceEntries.ts";
15import { WorkspacePaths } from "../Services/WorkspacePaths.ts";
16
17const PROJECT_READ_FILE_MAX_BYTES = 1024 * 1024;
18
19export 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
123export const WorkspaceFileSystemLive = Layer.effect(WorkspaceFileSystem, makeWorkspaceFileSystem);
apps/server/src/workspace/Layers/WorkspacePaths.test.ts deleted-134
...@@ -1,134 +0,0 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { it, describe, expect } from "@effect/vitest";
3import * as Effect from "effect/Effect";
4import * as FileSystem from "effect/FileSystem";
5import * as Layer from "effect/Layer";
6import * as Path from "effect/Path";
7
8import { WorkspacePaths } from "../Services/WorkspacePaths.ts";
9import { WorkspacePathsLive } from "./WorkspacePaths.ts";
10
11const TestLayer = Layer.empty.pipe(
12 Layer.provideMerge(WorkspacePathsLive),
13 Layer.provideMerge(NodeServices.layer),
14);
15
16const makeTempDir = Effect.fn("makeTempDir")(function* () {
17 const fileSystem = yield* FileSystem.FileSystem;
18 return yield* fileSystem.makeTempDirectoryScoped({
19 prefix: "t3code-project-paths-",
20 });
21});
22
23const 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
37it.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 @@
1import * as NodeOS from "node:os";
2import * as Effect from "effect/Effect";
3import * as FileSystem from "effect/FileSystem";
4import * as Layer from "effect/Layer";
5import * as Path from "effect/Path";
6
7import {
8 WorkspacePaths,
9 WorkspacePathOutsideRootError,
10 WorkspaceRootCreateFailedError,
11 WorkspaceRootNotDirectoryError,
12 WorkspaceRootNotExistsError,
13 type WorkspacePathsShape,
14} from "../Services/WorkspacePaths.ts";
15
16function toPosixRelativePath(input: string): string {
17 return input.replaceAll("\\", "/");
18}
19
20function 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
30export 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
107export 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 */
9import * as Schema from "effect/Schema";
10import * as Context from "effect/Context";
11import type * as Effect from "effect/Effect";
12
13import type {
14 ProjectReadFileInput,
15 ProjectReadFileResult,
16 ProjectWriteFileInput,
17 ProjectWriteFileResult,
18} from "@t3tools/contracts";
19import { WorkspacePathOutsideRootError } from "./WorkspacePaths.ts";
20
21export 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 */
39export 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 */
67export 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 */
9import * as Schema from "effect/Schema";
10import * as Context from "effect/Context";
11import type * as Effect from "effect/Effect";
12
13export 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
25export 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
37export 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
49export 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
61export const WorkspacePathsError = Schema.Union([
62 WorkspaceRootNotExistsError,
63 WorkspaceRootCreateFailedError,
64 WorkspaceRootNotDirectoryError,
65 WorkspacePathOutsideRootError,
66]);
67export type WorkspacePathsError = typeof WorkspacePathsError.Type;
68
69/**
70 * WorkspacePathsShape - Service API for workspace path normalization and guards.
71 */
72export 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 */
101export 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";
9import * as Path from "effect/Path";9import * as Path from "effect/Path";
10import * as PlatformError from "effect/PlatformError";10import * as PlatformError from "effect/PlatformError";
1111
12import { ServerConfig } from "../config.ts";12import * as ServerConfig from "../config.ts";
13import { HostProcessPlatform } from "@t3tools/shared/hostProcess";13import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
14import * as VcsProcess from "../vcs/VcsProcess.ts";14import * as VcsProcess from "../vcs/VcsProcess.ts";
15import * as WorkspaceEntries from "./WorkspaceEntries.ts";15import * as WorkspaceEntries from "./WorkspaceEntries.ts";
16import { WorkspacePathsLive } from "./Layers/WorkspacePaths.ts";16import * as WorkspacePaths from "./WorkspacePaths.ts";
1717
18const TestLayer = Layer.empty.pipe(18const 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);
365365
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 );
369372
apps/server/src/workspace/WorkspaceEntries.ts+84-51
...@@ -1,6 +1,6 @@...@@ -1,6 +1,6 @@
1// @effect-diagnostics nodeBuiltinImport:off1// @effect-diagnostics nodeBuiltinImport:off
2import * as NodeFSP from "node:fs/promises";2import { readdir } from "node:fs/promises";
3import * as NodeOS from "node:os";3import { homedir } from "node:os";
44
5import * as Context from "effect/Context";5import * as Context from "effect/Context";
6import * as Effect from "effect/Effect";6import * as Effect from "effect/Effect";
...@@ -20,29 +20,72 @@ import type {...@@ -20,29 +20,72 @@ import type {
20import { HostProcessPlatform } from "@t3tools/shared/hostProcess";20import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
21import { isExplicitRelativePath, isWindowsAbsolutePath } from "@t3tools/shared/path";21import { isExplicitRelativePath, isWindowsAbsolutePath } from "@t3tools/shared/path";
2222
23import * as WorkspacePaths from "./Services/WorkspacePaths.ts";23import * as WorkspacePaths from "./WorkspacePaths.ts";
24import * as WorkspaceSearchIndex from "./WorkspaceSearchIndex.ts";24import * as WorkspaceSearchIndex from "./WorkspaceSearchIndex.ts";
2525
26export class WorkspaceEntriesError extends Schema.TaggedErrorClass<WorkspaceEntriesError>()(26export 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}
3542
36export class WorkspaceEntriesBrowseError extends Schema.TaggedErrorClass<WorkspaceEntriesBrowseError>()(43export 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
57export 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
68export 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
83export const WorkspaceEntriesBrowseError = Schema.Union([
84 WorkspaceEntriesWindowsPathUnsupportedError,
85 WorkspaceEntriesCurrentProjectRequiredError,
86 WorkspaceEntriesReadDirectoryError,
87]);
88export type WorkspaceEntriesBrowseError = typeof WorkspaceEntriesBrowseError.Type;
4689
47export class WorkspaceEntries extends Context.Service<90export 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<
62105
63function expandHomePath(input: string, path: Path.Path): string {106function 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}
72115
73const resolveBrowseTarget = (116const 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)) {
87130 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) {
91134 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 });
103140
104const make = Effect.gen(function* () {141export 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);
157193
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 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { it, describe, expect } from "@effect/vitest";
3import * as Effect from "effect/Effect";
4import * as FileSystem from "effect/FileSystem";
5import * as Layer from "effect/Layer";
6import * as Path from "effect/Path";
7
8import * as ServerConfig from "../config.ts";
9import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts";
10import * as VcsProcess from "../vcs/VcsProcess.ts";
11import * as WorkspaceEntries from "./WorkspaceEntries.ts";
12import * as WorkspaceFileSystem from "./WorkspaceFileSystem.ts";
13import * as WorkspacePaths from "./WorkspacePaths.ts";
14
15const ProjectLayer = WorkspaceFileSystem.layer.pipe(
16 Layer.provide(WorkspacePaths.layer),
17 Layer.provide(WorkspaceEntries.layer.pipe(Layer.provide(WorkspacePaths.layer))),
18);
19
20const 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
33const makeTempDir = Effect.gen(function* () {
34 const fileSystem = yield* FileSystem.FileSystem;
35 return yield* fileSystem.makeTempDirectoryScoped({
36 prefix: "t3code-workspace-files-",
37 });
38});
39
40const 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
54it.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 */
10import { open, realpath } from "node:fs/promises";
11
12import type {
13 ProjectReadFileInput,
14 ProjectReadFileResult,
15 ProjectWriteFileInput,
16 ProjectWriteFileResult,
17} from "@t3tools/contracts";
18import * as Context from "effect/Context";
19import * as Effect from "effect/Effect";
20import * as FileSystem from "effect/FileSystem";
21import * as Layer from "effect/Layer";
22import * as Path from "effect/Path";
23import * as Schema from "effect/Schema";
24
25import * as WorkspaceEntries from "./WorkspaceEntries.ts";
26import * as WorkspacePaths from "./WorkspacePaths.ts";
27
28const PROJECT_READ_FILE_MAX_BYTES = 1024 * 1024;
29
30export 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. */
50export 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
75export 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
175export const layer = Layer.effect(WorkspaceFileSystem, make);
apps/server/src/workspace/WorkspacePaths.test.ts created+133
...@@ -0,0 +1,133 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { it, describe, expect } from "@effect/vitest";
3import * as Effect from "effect/Effect";
4import * as FileSystem from "effect/FileSystem";
5import * as Layer from "effect/Layer";
6import * as Path from "effect/Path";
7
8import * as WorkspacePaths from "./WorkspacePaths.ts";
9
10const TestLayer = Layer.empty.pipe(
11 Layer.provideMerge(WorkspacePaths.layer),
12 Layer.provideMerge(NodeServices.layer),
13);
14
15const makeTempDir = Effect.fn("makeTempDir")(function* () {
16 const fileSystem = yield* FileSystem.FileSystem;
17 return yield* fileSystem.makeTempDirectoryScoped({
18 prefix: "t3code-project-paths-",
19 });
20});
21
22const 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
36it.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 */
9import { homedir } from "node:os";
10
11import * as Context from "effect/Context";
12import * as Effect from "effect/Effect";
13import * as FileSystem from "effect/FileSystem";
14import * as Layer from "effect/Layer";
15import * as Path from "effect/Path";
16import * as Schema from "effect/Schema";
17
18export 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
30export 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
43export 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
55export 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
67export const WorkspacePathsError = Schema.Union([
68 WorkspaceRootNotExistsError,
69 WorkspaceRootCreateFailedError,
70 WorkspaceRootNotDirectoryError,
71 WorkspacePathOutsideRootError,
72]);
73export type WorkspacePathsError = typeof WorkspacePathsError.Type;
74
75/** Service tag for workspace path normalization and resolution. */
76export 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
102function toPosixRelativePath(input: string): string {
103 return input.replaceAll("\\", "/");
104}
105
106function 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
116export 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
191export 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});
184184
185const makeWorkspaceSearchIndex = (cwd: string) =>185export 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 }
199199 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 );
224201
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 });
231211
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 );
235226
236const 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 */
242export const layer = (cwd: string) => Layer.effect(WorkspaceSearchIndex, make(cwd));
238243
239export class WorkspaceSearchIndexMap extends LayerMap.Service<WorkspaceSearchIndexMap>()(244export 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";
79import { issueAssetUrl } from "./assets/AssetAccess.ts";79import { issueAssetUrl } from "./assets/AssetAccess.ts";
80import * as PortScanner from "./preview/PortScanner.ts";80import * as PortScanner from "./preview/PortScanner.ts";
81import * as WorkspaceEntries from "./workspace/WorkspaceEntries.ts";81import * as WorkspaceEntries from "./workspace/WorkspaceEntries.ts";
82import * as WorkspaceFileSystem from "./workspace/Services/WorkspaceFileSystem.ts";82import * as WorkspaceFileSystem from "./workspace/WorkspaceFileSystem.ts";
83import * as WorkspacePaths from "./workspace/Services/WorkspacePaths.ts";83import * as WorkspacePaths from "./workspace/WorkspacePaths.ts";
84import * as VcsStatusBroadcaster from "./vcs/VcsStatusBroadcaster.ts";84import * as VcsStatusBroadcaster from "./vcs/VcsStatusBroadcaster.ts";
85import * as VcsProvisioningService from "./vcs/VcsProvisioningService.ts";85import * as VcsProvisioningService from "./vcs/VcsProvisioningService.ts";
86import * as GitWorkflowService from "./git/GitWorkflowService.ts";86import * as GitWorkflowService from "./git/GitWorkflowService.ts";
87import * as ReviewService from "./review/ReviewService.ts";87import * as ReviewService from "./review/ReviewService.ts";
88import * as ProjectSetupScriptRunner from "./project/Services/ProjectSetupScriptRunner.ts";88import * as ProjectSetupScriptRunner from "./project/ProjectSetupScriptRunner.ts";
89import * as RepositoryIdentityResolver from "./project/Services/RepositoryIdentityResolver.ts";89import * as RepositoryIdentityResolver from "./project/RepositoryIdentityResolver.ts";
90import * as ServerEnvironment from "./environment/Services/ServerEnvironment.ts";90import * as ServerEnvironment from "./environment/ServerEnvironment.ts";
91import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts";91import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts";
92import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts";92import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts";
93import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts";93import * 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 ),