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 {
4646import { ProviderService } from "../src/provider/Services/ProviderService.ts";
4747import { AnalyticsService } from "../src/telemetry/Services/AnalyticsService.ts";
4848import { CheckpointReactorLive } from "../src/orchestration/Layers/CheckpointReactor.ts";
49import { RepositoryIdentityResolverLive } from "../src/project/Layers/RepositoryIdentityResolver.ts";
49import * as RepositoryIdentityResolver from "../src/project/RepositoryIdentityResolver.ts";
5050import { OrchestrationEngineLive } from "../src/orchestration/Layers/OrchestrationEngine.ts";
5151import { OrchestrationProjectionPipelineLive } from "../src/orchestration/Layers/ProjectionPipeline.ts";
5252import { OrchestrationProjectionSnapshotQueryLive } from "../src/orchestration/Layers/ProjectionSnapshotQuery.ts";
......@@ -72,7 +72,7 @@ import {
7272} from "./TestProviderAdapter.integration.ts";
7373import { deriveServerPaths, ServerConfig } from "../src/config.ts";
7474import * as WorkspaceEntries from "../src/workspace/WorkspaceEntries.ts";
75import { WorkspacePathsLive } from "../src/workspace/Layers/WorkspacePaths.ts";
75import * as WorkspacePaths from "../src/workspace/WorkspacePaths.ts";
7676import * as VcsDriverRegistry from "../src/vcs/VcsDriverRegistry.ts";
7777import { VcsStatusBroadcaster } from "../src/vcs/VcsStatusBroadcaster.ts";
7878import { GitWorkflowService } from "../src/git/GitWorkflowService.ts";
......@@ -348,12 +348,12 @@ export const makeOrchestrationIntegrationHarness = (
348348 ),
349349 Layer.provideMerge(
350350 WorkspaceEntries.layer.pipe(
351 Layer.provide(WorkspacePathsLive),
351 Layer.provide(WorkspacePaths.layer),
352352 Layer.provideMerge(VcsDriverRegistry.layer),
353353 Layer.provide(NodeServices.layer),
354354 ),
355355 ),
356 Layer.provideMerge(WorkspacePathsLive),
356 Layer.provideMerge(WorkspacePaths.layer),
357357 Layer.provideMerge(VcsProcess.layer),
358358 );
359359 const orchestrationReactorLayer = OrchestrationReactorLive.pipe(
......@@ -378,7 +378,7 @@ export const makeOrchestrationIntegrationHarness = (
378378 Layer.provideMerge(orchestrationReactorLayer),
379379 Layer.provideMerge(providerRegistryLayer),
380380 Layer.provide(persistenceLayer),
381 Layer.provideMerge(RepositoryIdentityResolverLive),
381 Layer.provideMerge(RepositoryIdentityResolver.layer),
382382 Layer.provideMerge(ServerSettingsService.layerTest()),
383383 Layer.provideMerge(ServerConfig.layerTest(workspaceDir, rootDir)),
384384 Layer.provideMerge(NodeServices.layer),
apps/server/src/assets/AssetAccess.test.ts+7-7
......@@ -7,18 +7,18 @@ import * as Layer from "effect/Layer";
77import * as Path from "effect/Path";
88
99import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
10import { ServerConfig } from "../config.ts";
11import { ProjectFaviconResolverLive } from "../project/Layers/ProjectFaviconResolver.ts";
12import { WorkspacePathsLive } from "../workspace/Layers/WorkspacePaths.ts";
10import * as ServerConfig from "../config.ts";
11import * as ProjectFaviconResolver from "../project/ProjectFaviconResolver.ts";
12import * as WorkspacePaths from "../workspace/WorkspacePaths.ts";
1313import { ASSET_ROUTE_PREFIX, issueAssetUrl, resolveAsset } from "./AssetAccess.ts";
1414
15const configLayer = ServerConfig.layerTest(process.cwd(), {
15const configLayer = ServerConfig.ServerConfig.layerTest(process.cwd(), {
1616 prefix: "t3-asset-access-test-",
1717});
1818const testLayer = Layer.mergeAll(
1919 configLayer,
20 WorkspacePathsLive,
21 ProjectFaviconResolverLive.pipe(Layer.provide(WorkspacePathsLive)),
20 WorkspacePaths.layer,
21 ProjectFaviconResolver.layer.pipe(Layer.provide(WorkspacePaths.layer)),
2222 ServerSecretStore.layer.pipe(Layer.provide(configLayer)),
2323).pipe(Layer.provideMerge(NodeServices.layer));
2424
......@@ -127,7 +127,7 @@ describe("AssetAccess", () => {
127127
128128 it.effect("issues exact attachment capabilities by attachment id", () =>
129129 Effect.gen(function* () {
130 const config = yield* ServerConfig;
130 const config = yield* ServerConfig.ServerConfig;
131131 const fileSystem = yield* FileSystem.FileSystem;
132132 const path = yield* Path.Path;
133133 const attachmentId = "thread-1-00000000-0000-4000-8000-000000000001";
apps/server/src/assets/AssetAccess.ts+5-5
......@@ -22,8 +22,8 @@ import {
2222import { ServerSecretStore } from "../auth/ServerSecretStore.ts";
2323import { resolveAttachmentPathById } from "../attachmentStore.ts";
2424import { ServerConfig } from "../config.ts";
25import { ProjectFaviconResolver } from "../project/Services/ProjectFaviconResolver.ts";
26import { WorkspacePaths } from "../workspace/Services/WorkspacePaths.ts";
25import * as ProjectFaviconResolver from "../project/ProjectFaviconResolver.ts";
26import * as WorkspacePaths from "../workspace/WorkspacePaths.ts";
2727
2828export const ASSET_ROUTE_PREFIX = "/api/assets";
2929export 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) =>
103103const resolveCanonicalWorkspaceFile = Effect.fn("AssetAccess.resolveCanonicalWorkspaceFile")(
104104 function* (input: { readonly workspaceRoot: string; readonly relativePath: string }) {
105105 const fileSystem = yield* FileSystem.FileSystem;
106 const workspacePaths = yield* WorkspacePaths;
106 const workspacePaths = yield* WorkspacePaths.WorkspacePaths;
107107 const resolved = yield* workspacePaths
108108 .resolveRelativePathWithinRoot(input)
109109 .pipe(Effect.orElseSucceed(() => null));
......@@ -130,7 +130,7 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i
130130}) {
131131 const fileSystem = yield* FileSystem.FileSystem;
132132 const path = yield* Path.Path;
133 const workspacePaths = yield* WorkspacePaths;
133 const workspacePaths = yield* WorkspacePaths.WorkspacePaths;
134134 const expiresAt = (yield* Clock.currentTimeMillis) + ASSET_TOKEN_TTL_MS;
135135 let claims: AssetClaims;
136136 let fileName: string;
......@@ -202,7 +202,7 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i
202202 const workspaceRoot = yield* workspacePaths
203203 .normalizeWorkspaceRoot(input.resource.cwd)
204204 .pipe(Effect.mapError((cause) => failAccess(cause.message, cause)));
205 const faviconResolver = yield* ProjectFaviconResolver;
205 const faviconResolver = yield* ProjectFaviconResolver.ProjectFaviconResolver;
206206 const faviconPath = yield* faviconResolver.resolvePath(workspaceRoot);
207207 const relativePath = faviconPath ? path.relative(workspaceRoot, faviconPath) : null;
208208 if (
apps/server/src/bin.test.ts+6-6
......@@ -1,5 +1,5 @@
11// @effect-diagnostics nodeBuiltinImport:off - CLI integration exercises Node HTTP and filesystem boundaries.
2import * as NodeHttp from "node:http";
2import { createServer } from "node:http";
33import { existsSync, mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
44import { tmpdir } from "node:os";
55import { join } from "node:path";
......@@ -25,12 +25,12 @@ import * as ProjectionSnapshotQuery from "./orchestration/Services/ProjectionSna
2525import { OrchestrationLayerLive } from "./orchestration/runtimeLayer.ts";
2626import { orchestrationHttpApiLayer } from "./orchestration/http.ts";
2727import { layerConfig as SqlitePersistenceLayerLive } from "./persistence/Layers/Sqlite.ts";
28import { RepositoryIdentityResolverLive } from "./project/Layers/RepositoryIdentityResolver.ts";
28import * as RepositoryIdentityResolver from "./project/RepositoryIdentityResolver.ts";
2929import {
3030 makePersistedServerRuntimeState,
3131 persistServerRuntimeState,
3232} from "./serverRuntimeState.ts";
33import { WorkspacePathsLive } from "./workspace/Layers/WorkspacePaths.ts";
33import * as WorkspacePaths from "./workspace/WorkspacePaths.ts";
3434import * as ServerSecretStore from "./auth/ServerSecretStore.ts";
3535import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts";
3636import { environmentAuthenticatedAuthLayer } from "./auth/http.ts";
......@@ -90,10 +90,10 @@ const makeCliTestServerConfig = (baseDir: string) =>
9090const makeProjectPersistenceLayer = (config: ServerConfig.ServerConfig["Service"]) =>
9191 Layer.mergeAll(
9292 OrchestrationLayerLive.pipe(
93 Layer.provideMerge(RepositoryIdentityResolverLive),
93 Layer.provideMerge(RepositoryIdentityResolver.layer),
9494 Layer.provideMerge(SqlitePersistenceLayerLive),
9595 ),
96 WorkspacePathsLive,
96 WorkspacePaths.layer,
9797 ).pipe(Layer.provideMerge(NodeServices.layer), Layer.provide(ServerConfig.layer(config)));
9898
9999const readPersistedSnapshot = (baseDir: string) =>
......@@ -124,7 +124,7 @@ const withLiveProjectCliServer = <A, E, R>(baseDir: string, run: () => Effect.Ef
124124 ),
125125 Layer.provideMerge(makeProjectPersistenceLayer(config)),
126126 Layer.provideMerge(
127 NodeHttpServer.layer(NodeHttp.createServer, {
127 NodeHttpServer.layer(createServer, {
128128 host: "127.0.0.1",
129129 port: 0,
130130 }),
apps/server/src/cli/connect.ts+2-3
......@@ -32,8 +32,7 @@ import { CLOUD_LINKED_USER_ID, RELAY_URL_SECRET } from "../cloud/config.ts";
3232import { relayUrlConfig } from "../cloud/publicConfig.ts";
3333import { headlessRelayClientTracingLayer } from "../cloud/relayTracing.ts";
3434import * as ServerConfig from "../config.ts";
35import { ServerEnvironmentLive } from "../environment/Layers/ServerEnvironment.ts";
36import * as ServerEnvironment from "../environment/Services/ServerEnvironment.ts";
35import * as ServerEnvironment from "../environment/ServerEnvironment.ts";
3736import { readPersistedServerRuntimeState } from "../serverRuntimeState.ts";
3837import { projectLocationFlags, resolveCliAuthConfig } from "./config.ts";
3938
......@@ -301,7 +300,7 @@ const runCloudCommand = <A, E>(
301300 CliTokenManager.layer.pipe(Layer.provide(ServerSecretStore.layer)),
302301 RelayClient.layerCloudflared({ baseDir: config.baseDir }),
303302 EnvironmentAuth.runtimeLayer,
304 ServerEnvironmentLive,
303 ServerEnvironment.layer,
305304 headlessRelayClientTracingLayer,
306305 ).pipe(
307306 Layer.provideMerge(FetchHttpClient.layer),
apps/server/src/cli/project.ts+5-6
......@@ -31,14 +31,13 @@ import * as OrchestrationEngine from "../orchestration/Services/OrchestrationEng
3131import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts";
3232import { OrchestrationLayerLive } from "../orchestration/runtimeLayer.ts";
3333import { layerConfig as SqlitePersistenceLayerLive } from "../persistence/Layers/Sqlite.ts";
34import { RepositoryIdentityResolverLive } from "../project/Layers/RepositoryIdentityResolver.ts";
34import * as RepositoryIdentityResolver from "../project/RepositoryIdentityResolver.ts";
3535import * as ServerRuntimeStartup from "../serverRuntimeStartup.ts";
3636import {
3737 clearPersistedServerRuntimeState,
3838 readPersistedServerRuntimeState,
3939} from "../serverRuntimeState.ts";
40import { WorkspacePathsLive } from "../workspace/Layers/WorkspacePaths.ts";
41import * as WorkspacePaths from "../workspace/Services/WorkspacePaths.ts";
40import * as WorkspacePaths from "../workspace/WorkspacePaths.ts";
4241import { type CliAuthLocationFlags, projectLocationFlags, resolveCliAuthConfig } from "./config.ts";
4342
4443type ProjectMutationTarget = {
......@@ -68,9 +67,9 @@ const projectCommandUuid = Crypto.Crypto.pipe(
6867);
6968
7069const ProjectCliRuntimeLive = Layer.mergeAll(
71 WorkspacePathsLive,
70 WorkspacePaths.layer,
7271 OrchestrationLayerLive.pipe(
73 Layer.provideMerge(RepositoryIdentityResolverLive),
72 Layer.provideMerge(RepositoryIdentityResolver.layer),
7473 Layer.provideMerge(SqlitePersistenceLayerLive),
7574 ),
7675);
......@@ -301,7 +300,7 @@ const runProjectMutation = Effect.fn("runProjectMutation")(function* (
301300 }).pipe(Effect.provide(offlineRuntimeLayer));
302301 }).pipe(
303302 Effect.provide(
304 Layer.mergeAll(EnvironmentAuth.runtimeLayer, WorkspacePathsLive).pipe(
303 Layer.mergeAll(EnvironmentAuth.runtimeLayer, WorkspacePaths.layer).pipe(
305304 Layer.provideMerge(FetchHttpClient.layer),
306305 Layer.provide(ServerConfig.layer(config)),
307306 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";
99import { RelayClientTracer } from "@t3tools/shared/relayTracing";
1010import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts";
1111import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
12import * as ServerEnvironment from "../environment/Services/ServerEnvironment.ts";
12import * as ServerEnvironment from "../environment/ServerEnvironment.ts";
1313import * as CliTokenManager from "./CliTokenManager.ts";
1414import { consumeCloudReplayGuards, reconcileDesiredCloudLink } from "./http.ts";
1515import * as ManagedEndpointRuntime from "./ManagedEndpointRuntime.ts";
apps/server/src/cloud/http.ts+3-3
......@@ -1,4 +1,4 @@
1import * as NodeCrypto from "node:crypto";
1import { createPublicKey } from "node:crypto";
22import {
33 AuthRelayReadScope,
44 AuthRelayWriteScope,
......@@ -55,7 +55,7 @@ import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder";
5555import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts";
5656import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
5757import { requireEnvironmentScope } from "../auth/http.ts";
58import * as ServerEnvironment from "../environment/Services/ServerEnvironment.ts";
58import * as ServerEnvironment from "../environment/ServerEnvironment.ts";
5959import * as ManagedEndpointRuntime from "./ManagedEndpointRuntime.ts";
6060import {
6161 CLOUD_ENDPOINT_RUNTIME_CONFIG,
......@@ -152,7 +152,7 @@ function validateCloudMintPublicKey(
152152 publicKey: string,
153153): Effect.Effect<void, EnvironmentHttpBadRequestError> {
154154 return Effect.try({
155 try: () => NodeCrypto.createPublicKey(publicKey.replace(/\\n/g, "\n")),
155 try: () => createPublicKey(publicKey.replace(/\\n/g, "\n")),
156156 catch: () =>
157157 new EnvironmentHttpBadRequestError({
158158 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 {
2020} from "@t3tools/contracts";
2121
2222import { GitCommandError, TextGenerationError } from "@t3tools/contracts";
23import * as GitManager from "./GitManager.ts";
2423import * as GitHubCli from "../sourceControl/GitHubCli.ts";
2524import * as TextGeneration from "../textGeneration/TextGeneration.ts";
2625import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
......@@ -28,8 +27,9 @@ import * as VcsProcess from "../vcs/VcsProcess.ts";
2827import * as GitHubSourceControlProvider from "../sourceControl/GitHubSourceControlProvider.ts";
2928import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts";
3029import * as ServerConfig from "../config.ts";
30import * as ProjectSetupScriptRunner from "../project/ProjectSetupScriptRunner.ts";
3131import * as ServerSettings from "../serverSettings.ts";
32import * as ProjectSetupScriptRunner from "../project/Services/ProjectSetupScriptRunner.ts";
32import * as GitManager from "./GitManager.ts";
3333
3434interface FakeGhScenario {
3535 prListSequence?: string[];
......@@ -3215,10 +3215,13 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
32153215 },
32163216 },
32173217 setupScriptRunner: {
3218 runForThread: () =>
3218 runForThread: (input) =>
32193219 Effect.fail(
3220 new ProjectSetupScriptRunner.ProjectSetupScriptRunnerError({
3221 message: "terminal start failed",
3220 new ProjectSetupScriptRunner.ProjectSetupScriptOperationError({
3221 threadId: input.threadId,
3222 worktreePath: input.worktreePath,
3223 operation: "openTerminal",
3224 cause: new Error("terminal start failed"),
32223225 }),
32233226 ),
32243227 },
apps/server/src/git/GitManager.ts+1-1
......@@ -43,7 +43,7 @@ import {
4343
4444import { GitManagerError } from "@t3tools/contracts";
4545import * as TextGeneration from "../textGeneration/TextGeneration.ts";
46import * as ProjectSetupScriptRunner from "../project/Services/ProjectSetupScriptRunner.ts";
46import * as ProjectSetupScriptRunner from "../project/ProjectSetupScriptRunner.ts";
4747import { extractBranchNameFromRemoteRef } from "./remoteRefs.ts";
4848import * as ServerSettings from "../serverSettings.ts";
4949import type { GitManagerServiceError } from "@t3tools/contracts";
apps/server/src/http.ts+1-1
......@@ -39,7 +39,7 @@ import {
3939 failEnvironmentAuthInvalid,
4040 failEnvironmentInternal,
4141} from "./auth/http.ts";
42import * as ServerEnvironment from "./environment/Services/ServerEnvironment.ts";
42import * as ServerEnvironment from "./environment/ServerEnvironment.ts";
4343import { browserApiCorsAllowedHeaders, browserApiCorsAllowedMethods } from "./httpCors.ts";
4444
4545const 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"
44import * as Effect from "effect/Effect";
55import { HttpServer } from "effect/unstable/http";
66
7import { ServerEnvironment } from "../environment/Services/ServerEnvironment.ts";
7import * as ServerEnvironment from "../environment/ServerEnvironment.ts";
88import * as McpSessionRegistry from "./McpSessionRegistry.ts";
99
1010const environmentId = EnvironmentId.make("environment-1");
......@@ -14,7 +14,7 @@ const makeFakeHttpServer = (hostname: string, port = 43123) =>
1414 serve: (() => Effect.void) as HttpServer.HttpServer["Service"]["serve"],
1515 });
1616const fakeHttpServer = makeFakeHttpServer("127.0.0.1");
17const fakeEnvironment = ServerEnvironment.of({
17const fakeEnvironment = ServerEnvironment.ServerEnvironment.of({
1818 getEnvironmentId: Effect.succeed(environmentId),
1919 getDescriptor: Effect.die("unused"),
2020});
......@@ -28,7 +28,7 @@ const makeRegistry = (now: () => number, httpServer = fakeHttpServer) =>
2828 })
2929 .pipe(
3030 Effect.provideService(HttpServer.HttpServer, httpServer),
31 Effect.provideService(ServerEnvironment, fakeEnvironment),
31 Effect.provideService(ServerEnvironment.ServerEnvironment, fakeEnvironment),
3232 Effect.provide(NodeServices.layer),
3333 );
3434
apps/server/src/mcp/McpSessionRegistry.ts+3-3
......@@ -7,7 +7,7 @@ import * as Layer from "effect/Layer";
77import * as SynchronizedRef from "effect/SynchronizedRef";
88import { HttpServer } from "effect/unstable/http";
99
10import { ServerEnvironment } from "../environment/Services/ServerEnvironment.ts";
10import * as ServerEnvironment from "../environment/ServerEnvironment.ts";
1111import * as McpInvocationContext from "./McpInvocationContext.ts";
1212import * as McpProviderSession from "./McpProviderSession.ts";
1313
......@@ -75,7 +75,7 @@ const makeWithOptions = Effect.fn("McpSessionRegistry.make")(function* (
7575 options: McpSessionRegistryOptions = {},
7676) {
7777 const crypto = yield* Crypto.Crypto;
78 const environment = yield* ServerEnvironment;
78 const environment = yield* ServerEnvironment.ServerEnvironment;
7979 const environmentId = yield* environment.getEnvironmentId;
8080 const httpServer = yield* HttpServer.HttpServer;
8181 const state = yield* SynchronizedRef.make<RegistryState>({ records: new Map() });
......@@ -194,7 +194,7 @@ const make = Effect.acquireRelease(
194194export const layer: Layer.Layer<
195195 McpSessionRegistry,
196196 never,
197 Crypto.Crypto | ServerEnvironment | HttpServer.HttpServer
197 Crypto.Crypto | ServerEnvironment.ServerEnvironment | HttpServer.HttpServer
198198> = Layer.effect(McpSessionRegistry, make);
199199
200200export const issueActiveMcpCredential = (
apps/server/src/orchestration/Layers/CheckpointReactor.test.ts+6-6
......@@ -34,7 +34,7 @@ import * as CheckpointStore from "../../checkpointing/CheckpointStore.ts";
3434import * as VcsDriverRegistry from "../../vcs/VcsDriverRegistry.ts";
3535import * as VcsProcess from "../../vcs/VcsProcess.ts";
3636import { VcsStatusBroadcaster } from "../../vcs/VcsStatusBroadcaster.ts";
37import { RepositoryIdentityResolverLive } from "../../project/Layers/RepositoryIdentityResolver.ts";
37import * as RepositoryIdentityResolver from "../../project/RepositoryIdentityResolver.ts";
3838import { CheckpointReactorLive } from "./CheckpointReactor.ts";
3939import { OrchestrationEngineLive } from "./OrchestrationEngine.ts";
4040import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts";
......@@ -56,7 +56,7 @@ import {
5656import { checkpointRefForThreadTurn } from "../../checkpointing/Utils.ts";
5757import { ServerConfig } from "../../config.ts";
5858import * as WorkspaceEntries from "../../workspace/WorkspaceEntries.ts";
59import { WorkspacePathsLive } from "../../workspace/Layers/WorkspacePaths.ts";
59import * as WorkspacePaths from "../../workspace/WorkspacePaths.ts";
6060
6161const asProjectId = (value: string): ProjectId => ProjectId.make(value);
6262const asTurnId = (value: string): TurnId => TurnId.make(value);
......@@ -294,11 +294,11 @@ describe("CheckpointReactor", () => {
294294 Layer.provide(OrchestrationProjectionPipelineLive),
295295 Layer.provide(OrchestrationEventStoreLive),
296296 Layer.provide(OrchestrationCommandReceiptRepositoryLive),
297 Layer.provide(RepositoryIdentityResolverLive),
297 Layer.provide(RepositoryIdentityResolver.layer),
298298 Layer.provide(SqlitePersistenceMemory),
299299 );
300300 const projectionSnapshotLayer = OrchestrationProjectionSnapshotQueryLive.pipe(
301 Layer.provide(RepositoryIdentityResolverLive),
301 Layer.provide(RepositoryIdentityResolver.layer),
302302 Layer.provide(SqlitePersistenceMemory),
303303 );
304304
......@@ -333,11 +333,11 @@ describe("CheckpointReactor", () => {
333333 Layer.provideMerge(CheckpointStore.layer.pipe(Layer.provide(VcsDriverRegistry.layer))),
334334 Layer.provideMerge(
335335 WorkspaceEntries.layer.pipe(
336 Layer.provide(WorkspacePathsLive),
336 Layer.provide(WorkspacePaths.layer),
337337 Layer.provideMerge(VcsDriverRegistry.layer),
338338 ),
339339 ),
340 Layer.provideMerge(WorkspacePathsLive),
340 Layer.provideMerge(WorkspacePaths.layer),
341341 Layer.provideMerge(VcsProcess.layer),
342342 Layer.provideMerge(ServerConfigLayer),
343343 Layer.provideMerge(NodeServices.layer),
apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts+5-5
......@@ -27,7 +27,7 @@ import {
2727 OrchestrationEventStore,
2828 type OrchestrationEventStoreShape,
2929} from "../../persistence/Services/OrchestrationEventStore.ts";
30import { RepositoryIdentityResolverLive } from "../../project/Layers/RepositoryIdentityResolver.ts";
30import * as RepositoryIdentityResolver from "../../project/RepositoryIdentityResolver.ts";
3131import { OrchestrationEngineLive } from "./OrchestrationEngine.ts";
3232import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts";
3333import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts";
......@@ -57,7 +57,7 @@ async function createOrchestrationSystem() {
5757 ).pipe(
5858 Layer.provide(OrchestrationEventStoreLive),
5959 Layer.provide(OrchestrationCommandReceiptRepositoryLive),
60 Layer.provide(RepositoryIdentityResolverLive),
60 Layer.provide(RepositoryIdentityResolver.layer),
6161 Layer.provide(SqlitePersistenceMemory),
6262 Layer.provideMerge(ServerConfigLayer),
6363 Layer.provideMerge(NodeServices.layer),
......@@ -680,7 +680,7 @@ describe("OrchestrationEngine", () => {
680680 Layer.provide(OrchestrationProjectionPipelineLive),
681681 Layer.provide(Layer.succeed(OrchestrationEventStore, flakyStore)),
682682 Layer.provide(OrchestrationCommandReceiptRepositoryLive),
683 Layer.provide(RepositoryIdentityResolverLive),
683 Layer.provide(RepositoryIdentityResolver.layer),
684684 Layer.provide(SqlitePersistenceMemory),
685685 Layer.provideMerge(ServerConfigLayer),
686686 Layer.provideMerge(NodeServices.layer),
......@@ -785,7 +785,7 @@ describe("OrchestrationEngine", () => {
785785 Layer.provide(Layer.succeed(OrchestrationProjectionPipeline, flakyProjectionPipeline)),
786786 Layer.provide(OrchestrationEventStoreLive),
787787 Layer.provide(OrchestrationCommandReceiptRepositoryLive),
788 Layer.provide(RepositoryIdentityResolverLive),
788 Layer.provide(RepositoryIdentityResolver.layer),
789789 Layer.provide(SqlitePersistenceMemory),
790790 Layer.provide(NodeServices.layer),
791791 ),
......@@ -928,7 +928,7 @@ describe("OrchestrationEngine", () => {
928928 Layer.provide(Layer.succeed(OrchestrationProjectionPipeline, flakyProjectionPipeline)),
929929 Layer.provide(Layer.succeed(OrchestrationEventStore, nonTransactionalStore)),
930930 Layer.provide(OrchestrationCommandReceiptRepositoryLive),
931 Layer.provide(RepositoryIdentityResolverLive),
931 Layer.provide(RepositoryIdentityResolver.layer),
932932 Layer.provide(SqlitePersistenceMemory),
933933 Layer.provide(NodeServices.layer),
934934 ),
apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts+2-2
......@@ -24,7 +24,7 @@ import {
2424 SqlitePersistenceMemory,
2525} from "../../persistence/Layers/Sqlite.ts";
2626import { OrchestrationEventStore } from "../../persistence/Services/OrchestrationEventStore.ts";
27import { RepositoryIdentityResolverLive } from "../../project/Layers/RepositoryIdentityResolver.ts";
27import * as RepositoryIdentityResolver from "../../project/RepositoryIdentityResolver.ts";
2828import { OrchestrationEngineLive } from "./OrchestrationEngine.ts";
2929import {
3030 ORCHESTRATION_PROJECTOR_NAMES,
......@@ -2535,7 +2535,7 @@ const engineLayer = it.layer(
25352535 Layer.provide(OrchestrationProjectionPipelineLive),
25362536 Layer.provide(OrchestrationEventStoreLive),
25372537 Layer.provide(OrchestrationCommandReceiptRepositoryLive),
2538 Layer.provide(RepositoryIdentityResolverLive),
2538 Layer.provide(RepositoryIdentityResolver.layer),
25392539 Layer.provideMerge(SqlitePersistenceMemory),
25402540 Layer.provideMerge(
25412541 ServerConfig.layerTest(process.cwd(), {
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts+3-4
......@@ -14,8 +14,7 @@ import * as Layer from "effect/Layer";
1414import * as SqlClient from "effect/unstable/sql/SqlClient";
1515
1616import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts";
17import { RepositoryIdentityResolver } from "../../project/Services/RepositoryIdentityResolver.ts";
18import { RepositoryIdentityResolverLive } from "../../project/Layers/RepositoryIdentityResolver.ts";
17import * as RepositoryIdentityResolver from "../../project/RepositoryIdentityResolver.ts";
1918import { ORCHESTRATION_PROJECTOR_NAMES } from "./ProjectionPipeline.ts";
2019import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts";
2120import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts";
......@@ -28,7 +27,7 @@ const asCheckpointRef = (value: string): CheckpointRef => CheckpointRef.make(val
2827
2928const projectionSnapshotLayer = it.layer(
3029 OrchestrationProjectionSnapshotQueryLive.pipe(
31 Layer.provideMerge(RepositoryIdentityResolverLive),
30 Layer.provideMerge(RepositoryIdentityResolver.layer),
3231 Layer.provideMerge(SqlitePersistenceMemory),
3332 Layer.provideMerge(NodeServices.layer),
3433 ),
......@@ -1441,7 +1440,7 @@ it.effect(
14411440 const resolveCalls: string[] = [];
14421441 const layer = OrchestrationProjectionSnapshotQueryLive.pipe(
14431442 Layer.provideMerge(
1444 Layer.succeed(RepositoryIdentityResolver, {
1443 Layer.succeed(RepositoryIdentityResolver.RepositoryIdentityResolver, {
14451444 resolve: (cwd: string) =>
14461445 Effect.sync(() => {
14471446 resolveCalls.push(cwd);
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts+2-2
......@@ -48,7 +48,7 @@ import { ProjectionThreadMessage } from "../../persistence/Services/ProjectionTh
4848import { ProjectionThreadProposedPlan } from "../../persistence/Services/ProjectionThreadProposedPlans.ts";
4949import { ProjectionThreadSession } from "../../persistence/Services/ProjectionThreadSessions.ts";
5050import { ProjectionThread } from "../../persistence/Services/ProjectionThreads.ts";
51import { RepositoryIdentityResolver } from "../../project/Services/RepositoryIdentityResolver.ts";
51import * as RepositoryIdentityResolver from "../../project/RepositoryIdentityResolver.ts";
5252import { ORCHESTRATION_PROJECTOR_NAMES } from "./ProjectionPipeline.ts";
5353import {
5454 ProjectionSnapshotQuery,
......@@ -262,7 +262,7 @@ function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: st
262262
263263const makeProjectionSnapshotQuery = Effect.gen(function* () {
264264 const sql = yield* SqlClient.SqlClient;
265 const repositoryIdentityResolver = yield* RepositoryIdentityResolver;
265 const repositoryIdentityResolver = yield* RepositoryIdentityResolver.RepositoryIdentityResolver;
266266 const repositoryIdentityResolutionConcurrency = 4;
267267 const resolveRepositoryIdentitiesForProjects = Effect.fn(
268268 "ProjectionSnapshotQuery.resolveRepositoryIdentitiesForProjects",
apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts+3-3
......@@ -43,7 +43,7 @@ import {
4343} from "../../provider/Services/ProviderService.ts";
4444import { makeProviderRegistryLayer } from "../../provider/testUtils/providerRegistryMock.ts";
4545import { TextGeneration, type TextGenerationShape } from "../../textGeneration/TextGeneration.ts";
46import { RepositoryIdentityResolverLive } from "../../project/Layers/RepositoryIdentityResolver.ts";
46import * as RepositoryIdentityResolver from "../../project/RepositoryIdentityResolver.ts";
4747import { OrchestrationEngineLive } from "./OrchestrationEngine.ts";
4848import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts";
4949import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts";
......@@ -335,11 +335,11 @@ describe("ProviderCommandReactor", () => {
335335 Layer.provide(OrchestrationProjectionPipelineLive),
336336 Layer.provide(OrchestrationEventStoreLive),
337337 Layer.provide(OrchestrationCommandReceiptRepositoryLive),
338 Layer.provide(RepositoryIdentityResolverLive),
338 Layer.provide(RepositoryIdentityResolver.layer),
339339 Layer.provide(SqlitePersistenceMemory),
340340 );
341341 const projectionSnapshotLayer = OrchestrationProjectionSnapshotQueryLive.pipe(
342 Layer.provide(RepositoryIdentityResolverLive),
342 Layer.provide(RepositoryIdentityResolver.layer),
343343 Layer.provide(SqlitePersistenceMemory),
344344 );
345345 const layer = ProviderCommandReactorLive.pipe(
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts+3-3
......@@ -39,7 +39,7 @@ import {
3939 ProviderService,
4040 type ProviderServiceShape,
4141} from "../../provider/Services/ProviderService.ts";
42import { RepositoryIdentityResolverLive } from "../../project/Layers/RepositoryIdentityResolver.ts";
42import * as RepositoryIdentityResolver from "../../project/RepositoryIdentityResolver.ts";
4343import { OrchestrationEngineLive } from "./OrchestrationEngine.ts";
4444import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts";
4545import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts";
......@@ -226,11 +226,11 @@ describe("ProviderRuntimeIngestion", () => {
226226 Layer.provide(OrchestrationProjectionPipelineLive),
227227 Layer.provide(OrchestrationEventStoreLive),
228228 Layer.provide(OrchestrationCommandReceiptRepositoryLive),
229 Layer.provide(RepositoryIdentityResolverLive),
229 Layer.provide(RepositoryIdentityResolver.layer),
230230 Layer.provide(SqlitePersistenceMemory),
231231 );
232232 const projectionSnapshotLayer = OrchestrationProjectionSnapshotQueryLive.pipe(
233 Layer.provide(RepositoryIdentityResolverLive),
233 Layer.provide(RepositoryIdentityResolver.layer),
234234 Layer.provide(SqlitePersistenceMemory),
235235 );
236236 const layer = ProviderRuntimeIngestionLive.pipe(
apps/server/src/orchestration/Normalizer.ts+2-2
......@@ -11,14 +11,14 @@ import {
1111import { createAttachmentId, resolveAttachmentPath } from "../attachmentStore.ts";
1212import { ServerConfig } from "../config.ts";
1313import { parseBase64DataUrl } from "../imageMime.ts";
14import { WorkspacePaths } from "../workspace/Services/WorkspacePaths.ts";
14import * as WorkspacePaths from "../workspace/WorkspacePaths.ts";
1515
1616export const normalizeDispatchCommand = (command: ClientOrchestrationCommand) =>
1717 Effect.gen(function* () {
1818 const fileSystem = yield* FileSystem.FileSystem;
1919 const path = yield* Path.Path;
2020 const serverConfig = yield* ServerConfig;
21 const workspacePaths = yield* WorkspacePaths;
21 const workspacePaths = yield* WorkspacePaths.WorkspacePaths;
2222
2323 const normalizeProjectWorkspaceRoot = (workspaceRoot: string) =>
2424 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 @@
1import * as NodeCrypto from "node:crypto";
1import { generateKeyPairSync } from "node:crypto";
22import * as NodeServices from "@effect/platform-node/NodeServices";
33
44import type {
......@@ -29,7 +29,7 @@ import * as Stream from "effect/Stream";
2929import * as Tracer from "effect/Tracer";
3030
3131import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
32import { ServerEnvironment } from "../environment/Services/ServerEnvironment.ts";
32import * as ServerEnvironment from "../environment/ServerEnvironment.ts";
3333import {
3434 OrchestrationEngineService,
3535 type OrchestrationEngineShape,
......@@ -348,7 +348,7 @@ describe.sequential("signRelayAgentActivityPublishProof", () => {
348348 });
349349
350350 it("signs the activity publish JWT and rejects tampering", async () => {
351 const keyPair = NodeCrypto.generateKeyPairSync("ed25519", {
351 const keyPair = generateKeyPairSync("ed25519", {
352352 privateKeyEncoding: { format: "pem", type: "pkcs8" },
353353 publicKeyEncoding: { format: "pem", type: "spki" },
354354 });
......@@ -494,7 +494,7 @@ describe.sequential("signRelayAgentActivityPublishProof", () => {
494494
495495 const layer = Layer.mergeAll(
496496 Layer.succeed(ServerSecretStore.ServerSecretStore, secrets.store),
497 Layer.succeed(ServerEnvironment, {
497 Layer.succeed(ServerEnvironment.ServerEnvironment, {
498498 getEnvironmentId: Effect.succeed(environmentId),
499499 getDescriptor: Effect.succeed(descriptor),
500500 }),
......@@ -642,7 +642,7 @@ describe.sequential("signRelayAgentActivityPublishProof", () => {
642642
643643 const layer = Layer.mergeAll(
644644 Layer.succeed(ServerSecretStore.ServerSecretStore, secrets.store),
645 Layer.succeed(ServerEnvironment, {
645 Layer.succeed(ServerEnvironment.ServerEnvironment, {
646646 getEnvironmentId: Effect.succeed(environmentId),
647647 getDescriptor: Effect.succeed(descriptor),
648648 }),
apps/server/src/relay/AgentAwarenessRelay.ts+1-1
......@@ -41,7 +41,7 @@ import {
4141 RELAY_URL_SECRET,
4242} from "../cloud/config.ts";
4343import { getOrCreateEnvironmentKeyPairFromSecretStore } from "../cloud/environmentKeys.ts";
44import * as ServerEnvironment from "../environment/Services/ServerEnvironment.ts";
44import * as ServerEnvironment from "../environment/ServerEnvironment.ts";
4545import * as OrchestrationEngine from "../orchestration/Services/OrchestrationEngine.ts";
4646import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts";
4747
apps/server/src/server.test.ts+42-41
......@@ -1,7 +1,7 @@
11import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer";
22import * as NodeSocket from "@effect/platform-node/NodeSocket";
33import * as NodeServices from "@effect/platform-node/NodeServices";
4import * as NodeCrypto from "node:crypto";
4import { generateKeyPairSync, type KeyObject, sign } from "node:crypto";
55
66import {
77 AuthAccessTokenType,
......@@ -89,13 +89,13 @@ import * as TerminalManager from "./terminal/Manager.ts";
8989import * as PreviewManager from "./preview/Manager.ts";
9090import * as PortScanner from "./preview/PortScanner.ts";
9191import * as BrowserTraceCollector from "./observability/BrowserTraceCollector.ts";
92import { ProjectFaviconResolverLive } from "./project/Layers/ProjectFaviconResolver.ts";
93import * as ProjectSetupScriptRunner from "./project/Services/ProjectSetupScriptRunner.ts";
94import * as RepositoryIdentityResolver from "./project/Services/RepositoryIdentityResolver.ts";
95import * as ServerEnvironment from "./environment/Services/ServerEnvironment.ts";
92import * as ProjectFaviconResolver from "./project/ProjectFaviconResolver.ts";
93import * as ProjectSetupScriptRunner from "./project/ProjectSetupScriptRunner.ts";
94import * as RepositoryIdentityResolver from "./project/RepositoryIdentityResolver.ts";
95import * as ServerEnvironment from "./environment/ServerEnvironment.ts";
9696import * as WorkspaceEntries from "./workspace/WorkspaceEntries.ts";
97import { WorkspaceFileSystemLive } from "./workspace/Layers/WorkspaceFileSystem.ts";
98import { WorkspacePathsLive } from "./workspace/Layers/WorkspacePaths.ts";
97import * as WorkspaceFileSystem from "./workspace/WorkspaceFileSystem.ts";
98import * as WorkspacePaths from "./workspace/WorkspacePaths.ts";
9999import * as GitVcsDriver from "./vcs/GitVcsDriver.ts";
100100import * as VcsDriver from "./vcs/VcsDriver.ts";
101101import * as VcsStatusBroadcaster from "./vcs/VcsStatusBroadcaster.ts";
......@@ -485,17 +485,17 @@ const buildAppUnderTest = (options?: {
485485 ...options?.layers?.gitManager,
486486 });
487487 const workspaceEntriesLayer = WorkspaceEntries.layer.pipe(
488 Layer.provide(WorkspacePathsLive),
488 Layer.provide(WorkspacePaths.layer),
489489 Layer.provideMerge(vcsDriverRegistryLayer),
490490 );
491491 const workspaceAndProjectServicesLayer = Layer.mergeAll(
492 WorkspacePathsLive,
492 WorkspacePaths.layer,
493493 workspaceEntriesLayer,
494 WorkspaceFileSystemLive.pipe(
495 Layer.provide(WorkspacePathsLive),
494 WorkspaceFileSystem.layer.pipe(
495 Layer.provide(WorkspacePaths.layer),
496496 Layer.provide(workspaceEntriesLayer),
497497 ),
498 ProjectFaviconResolverLive.pipe(Layer.provide(WorkspacePathsLive)),
498 ProjectFaviconResolver.layer.pipe(Layer.provide(WorkspacePaths.layer)),
499499 );
500500 const gitWorkflowLayer = GitWorkflowService.layer.pipe(
501501 Layer.provideMerge(vcsDriverRegistryLayer),
......@@ -950,14 +950,14 @@ const makeDpopProof = (input: {
950950 readonly iat: number;
951951 readonly accessToken?: string;
952952 readonly jti?: string;
953 readonly privateKey?: NodeCrypto.KeyObject;
953 readonly privateKey?: KeyObject;
954954 readonly publicJwk?: DpopPublicJwk;
955955}) => {
956956 const keyPair =
957957 input.privateKey && input.publicJwk
958958 ? { privateKey: input.privateKey, publicJwk: input.publicJwk }
959959 : (() => {
960 const { privateKey, publicKey } = NodeCrypto.generateKeyPairSync("ec", {
960 const { privateKey, publicKey } = generateKeyPairSync("ec", {
961961 namedCurve: "P-256",
962962 });
963963 return { privateKey, publicJwk: publicKey.export({ format: "jwk" }) as DpopPublicJwk };
......@@ -978,7 +978,7 @@ const makeDpopProof = (input: {
978978 ...(input.accessToken ? { ath: computeDpopAccessTokenHash(input.accessToken) } : {}),
979979 }),
980980 ).toString("base64url");
981 const signature = NodeCrypto.sign("sha256", Buffer.from(`${header}.${payload}`), {
981 const signature = sign("sha256", Buffer.from(`${header}.${payload}`), {
982982 key: keyPair.privateKey,
983983 dsaEncoding: "ieee-p1363",
984984 }).toString("base64url");
......@@ -1024,7 +1024,7 @@ const makeCloudMintCredentialRequest = (input: {
10241024 const encodedPayload = Buffer.from(JSON.stringify(payload)).toString("base64url");
10251025 const signingInput = `${header}.${encodedPayload}`;
10261026 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")}`,
10281028 };
10291029};
10301030
......@@ -1057,7 +1057,7 @@ const makeCloudEnvironmentHealthRequest = (input: {
10571057 const encodedPayload = Buffer.from(JSON.stringify(payload)).toString("base64url");
10581058 const signingInput = `${header}.${encodedPayload}`;
10591059 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")}`,
10611061 };
10621062};
10631063
......@@ -2054,7 +2054,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
20542054 Effect.gen(function* () {
20552055 yield* buildAppUnderTest();
20562056
2057 const cloudKeyPair = NodeCrypto.generateKeyPairSync("ed25519", {
2057 const cloudKeyPair = generateKeyPairSync("ed25519", {
20582058 privateKeyEncoding: { format: "pem", type: "pkcs8" },
20592059 publicKeyEncoding: { format: "pem", type: "spki" },
20602060 });
......@@ -2131,7 +2131,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
21312131 Effect.gen(function* () {
21322132 yield* buildAppUnderTest();
21332133
2134 const cloudKeyPair = NodeCrypto.generateKeyPairSync("ed25519", {
2134 const cloudKeyPair = generateKeyPairSync("ed25519", {
21352135 privateKeyEncoding: { format: "pem", type: "pkcs8" },
21362136 publicKeyEncoding: { format: "pem", type: "spki" },
21372137 });
......@@ -2174,7 +2174,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
21742174 Effect.gen(function* () {
21752175 yield* buildAppUnderTest();
21762176
2177 const cloudKeyPair = NodeCrypto.generateKeyPairSync("ed25519", {
2177 const cloudKeyPair = generateKeyPairSync("ed25519", {
21782178 privateKeyEncoding: { format: "pem", type: "pkcs8" },
21792179 publicKeyEncoding: { format: "pem", type: "spki" },
21802180 });
......@@ -2268,7 +2268,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
22682268 },
22692269 });
22702270
2271 const cloudKeyPair = NodeCrypto.generateKeyPairSync("ed25519", {
2271 const cloudKeyPair = generateKeyPairSync("ed25519", {
22722272 privateKeyEncoding: { format: "pem", type: "pkcs8" },
22732273 publicKeyEncoding: { format: "pem", type: "spki" },
22742274 });
......@@ -2345,7 +2345,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
23452345 Effect.gen(function* () {
23462346 yield* buildAppUnderTest();
23472347
2348 const cloudKeyPair = NodeCrypto.generateKeyPairSync("ed25519", {
2348 const cloudKeyPair = generateKeyPairSync("ed25519", {
23492349 privateKeyEncoding: { format: "pem", type: "pkcs8" },
23502350 publicKeyEncoding: { format: "pem", type: "spki" },
23512351 });
......@@ -2404,7 +2404,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
24042404 Effect.gen(function* () {
24052405 yield* buildAppUnderTest();
24062406
2407 const cloudKeyPair = NodeCrypto.generateKeyPairSync("ed25519", {
2407 const cloudKeyPair = generateKeyPairSync("ed25519", {
24082408 privateKeyEncoding: { format: "pem", type: "pkcs8" },
24092409 publicKeyEncoding: { format: "pem", type: "spki" },
24102410 });
......@@ -2463,7 +2463,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
24632463 Effect.gen(function* () {
24642464 yield* buildAppUnderTest();
24652465
2466 const cloudKeyPair = NodeCrypto.generateKeyPairSync("ed25519", {
2466 const cloudKeyPair = generateKeyPairSync("ed25519", {
24672467 privateKeyEncoding: { format: "pem", type: "pkcs8" },
24682468 publicKeyEncoding: { format: "pem", type: "spki" },
24692469 });
......@@ -2523,7 +2523,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
25232523 Effect.gen(function* () {
25242524 yield* buildAppUnderTest();
25252525
2526 const cloudKeyPair = NodeCrypto.generateKeyPairSync("ed25519", {
2526 const cloudKeyPair = generateKeyPairSync("ed25519", {
25272527 privateKeyEncoding: { format: "pem", type: "pkcs8" },
25282528 publicKeyEncoding: { format: "pem", type: "spki" },
25292529 });
......@@ -2584,7 +2584,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
25842584 Effect.gen(function* () {
25852585 yield* buildAppUnderTest();
25862586
2587 const cloudKeyPair = NodeCrypto.generateKeyPairSync("ed25519", {
2587 const cloudKeyPair = generateKeyPairSync("ed25519", {
25882588 privateKeyEncoding: { format: "pem", type: "pkcs8" },
25892589 publicKeyEncoding: { format: "pem", type: "spki" },
25902590 });
......@@ -2664,7 +2664,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
26642664 },
26652665 });
26662666
2667 const cloudKeyPair = NodeCrypto.generateKeyPairSync("ed25519", {
2667 const cloudKeyPair = generateKeyPairSync("ed25519", {
26682668 privateKeyEncoding: { format: "pem", type: "pkcs8" },
26692669 publicKeyEncoding: { format: "pem", type: "spki" },
26702670 });
......@@ -2733,7 +2733,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
27332733 Effect.gen(function* () {
27342734 yield* buildAppUnderTest();
27352735
2736 const cloudKeyPair = NodeCrypto.generateKeyPairSync("ed25519", {
2736 const cloudKeyPair = generateKeyPairSync("ed25519", {
27372737 privateKeyEncoding: { format: "pem", type: "pkcs8" },
27382738 publicKeyEncoding: { format: "pem", type: "spki" },
27392739 });
......@@ -2800,7 +2800,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
28002800 Effect.gen(function* () {
28012801 yield* buildAppUnderTest();
28022802
2803 const cloudKeyPair = NodeCrypto.generateKeyPairSync("ed25519", {
2803 const cloudKeyPair = generateKeyPairSync("ed25519", {
28042804 privateKeyEncoding: { format: "pem", type: "pkcs8" },
28052805 publicKeyEncoding: { format: "pem", type: "spki" },
28062806 });
......@@ -2851,7 +2851,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
28512851 Effect.gen(function* () {
28522852 yield* buildAppUnderTest();
28532853
2854 const cloudKeyPair = NodeCrypto.generateKeyPairSync("ed25519", {
2854 const cloudKeyPair = generateKeyPairSync("ed25519", {
28552855 privateKeyEncoding: { format: "pem", type: "pkcs8" },
28562856 publicKeyEncoding: { format: "pem", type: "spki" },
28572857 });
......@@ -2902,7 +2902,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
29022902 Effect.gen(function* () {
29032903 yield* buildAppUnderTest();
29042904
2905 const cloudKeyPair = NodeCrypto.generateKeyPairSync("ed25519", {
2905 const cloudKeyPair = generateKeyPairSync("ed25519", {
29062906 privateKeyEncoding: { format: "pem", type: "pkcs8" },
29072907 publicKeyEncoding: { format: "pem", type: "spki" },
29082908 });
......@@ -2967,7 +2967,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
29672967 Effect.gen(function* () {
29682968 yield* buildAppUnderTest();
29692969
2970 const cloudKeyPair = NodeCrypto.generateKeyPairSync("ed25519", {
2970 const cloudKeyPair = generateKeyPairSync("ed25519", {
29712971 privateKeyEncoding: { format: "pem", type: "pkcs8" },
29722972 publicKeyEncoding: { format: "pem", type: "spki" },
29732973 });
......@@ -3017,7 +3017,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
30173017 Effect.gen(function* () {
30183018 yield* buildAppUnderTest();
30193019
3020 const cloudKeyPair = NodeCrypto.generateKeyPairSync("ed25519", {
3020 const cloudKeyPair = generateKeyPairSync("ed25519", {
30213021 privateKeyEncoding: { format: "pem", type: "pkcs8" },
30223022 publicKeyEncoding: { format: "pem", type: "spki" },
30233023 });
......@@ -4447,10 +4447,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
44474447
44484448 assertTrue(result._tag === "Failure");
44494449 assertTrue(result.failure._tag === "ProjectSearchEntriesError");
4450 assertInclude(
4451 result.failure.message,
4452 "Workspace root does not exist: /definitely/not/a/real/workspace/path",
4453 );
4450 assert.equal(result.failure.message, "Failed to search workspace entries.");
44544451 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
44554452 );
44564453
......@@ -6096,13 +6093,16 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
60966093 );
60976094 const runForThread = vi.fn(
60986095 (
6099 _: Parameters<
6096 input: Parameters<
61006097 ProjectSetupScriptRunner.ProjectSetupScriptRunner["Service"]["runForThread"]
61016098 >[0],
61026099 ) =>
61036100 Effect.fail(
6104 new ProjectSetupScriptRunner.ProjectSetupScriptRunnerError({
6105 message: "pty unavailable",
6101 new ProjectSetupScriptRunner.ProjectSetupScriptOperationError({
6102 threadId: input.threadId,
6103 worktreePath: input.worktreePath,
6104 operation: "openTerminal",
6105 cause: new Error("pty unavailable"),
61066106 }),
61076107 ),
61086108 );
......@@ -6177,7 +6177,8 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
61776177 );
61786178 assert.equal(setupFailureActivity?.activity.kind, "setup-script.failed");
61796179 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'.",
61816182 worktreePath: "/tmp/bootstrap-worktree",
61826183 });
61836184 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";
5252import { hasCloudPublicConfig } from "./cloud/publicConfig.ts";
5353import { ProviderRegistryLive } from "./provider/Layers/ProviderRegistry.ts";
5454import * as ServerSettings from "./serverSettings.ts";
55import { ProjectFaviconResolverLive } from "./project/Layers/ProjectFaviconResolver.ts";
56import { RepositoryIdentityResolverLive } from "./project/Layers/RepositoryIdentityResolver.ts";
55import * as ProjectFaviconResolver from "./project/ProjectFaviconResolver.ts";
56import * as RepositoryIdentityResolver from "./project/RepositoryIdentityResolver.ts";
5757import * as WorkspaceEntries from "./workspace/WorkspaceEntries.ts";
58import { WorkspaceFileSystemLive } from "./workspace/Layers/WorkspaceFileSystem.ts";
59import { WorkspacePathsLive } from "./workspace/Layers/WorkspacePaths.ts";
58import * as WorkspaceFileSystem from "./workspace/WorkspaceFileSystem.ts";
59import * as WorkspacePaths from "./workspace/WorkspacePaths.ts";
6060import * as GitVcsDriver from "./vcs/GitVcsDriver.ts";
6161import * as VcsDriverRegistry from "./vcs/VcsDriverRegistry.ts";
6262import * as VcsProjectConfig from "./vcs/VcsProjectConfig.ts";
......@@ -67,9 +67,9 @@ import * as GitWorkflowService from "./git/GitWorkflowService.ts";
6767import * as ReviewService from "./review/ReviewService.ts";
6868import * as SourceControlProviderRegistry from "./sourceControl/SourceControlProviderRegistry.ts";
6969import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts";
70import { ProjectSetupScriptRunnerLive } from "./project/Layers/ProjectSetupScriptRunner.ts";
70import * as ProjectSetupScriptRunner from "./project/ProjectSetupScriptRunner.ts";
7171import { ObservabilityLive } from "./observability/Layers/Observability.ts";
72import { ServerEnvironmentLive } from "./environment/Layers/ServerEnvironment.ts";
72import * as ServerEnvironment from "./environment/ServerEnvironment.ts";
7373import { authHttpApiLayer, environmentAuthenticatedAuthLayer } from "./auth/http.ts";
7474import * as ServerSecretStore from "./auth/ServerSecretStore.ts";
7575import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts";
......@@ -195,7 +195,7 @@ const SourceControlProviderRegistryLayerLive = SourceControlProviderRegistry.lay
195195);
196196
197197const GitManagerLayerLive = GitManager.layer.pipe(
198 Layer.provideMerge(ProjectSetupScriptRunnerLive),
198 Layer.provideMerge(ProjectSetupScriptRunner.layer),
199199 Layer.provideMerge(GitVcsDriver.layer),
200200 Layer.provideMerge(SourceControlProviderRegistryLayerLive),
201201 Layer.provideMerge(TextGeneration.layer),
......@@ -248,21 +248,21 @@ const PreviewLayerLive = Layer.empty.pipe(
248248 Layer.provideMerge(PortScannerLayerLive),
249249);
250250
251const WorkspaceEntriesLayerLive = WorkspaceEntries.layer.pipe(Layer.provide(WorkspacePathsLive));
251const WorkspaceEntriesLayerLive = WorkspaceEntries.layer.pipe(Layer.provide(WorkspacePaths.layer));
252252
253const WorkspaceFileSystemLayerLive = WorkspaceFileSystemLive.pipe(
254 Layer.provide(WorkspacePathsLive),
253const WorkspaceFileSystemLayerLive = WorkspaceFileSystem.layer.pipe(
254 Layer.provide(WorkspacePaths.layer),
255255 Layer.provide(WorkspaceEntriesLayerLive),
256256);
257257
258258const WorkspaceLayerLive = Layer.mergeAll(
259 WorkspacePathsLive,
259 WorkspacePaths.layer,
260260 WorkspaceEntriesLayerLive,
261261 WorkspaceFileSystemLayerLive,
262262);
263263
264const ProjectFaviconResolverLayerLive = ProjectFaviconResolverLive.pipe(
265 Layer.provide(WorkspacePathsLive),
264const ProjectFaviconResolverLayerLive = ProjectFaviconResolver.layer.pipe(
265 Layer.provide(WorkspacePaths.layer),
266266);
267267
268268const AuthLayerLive = EnvironmentAuth.layer.pipe(
......@@ -315,8 +315,8 @@ const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe(
315315 Layer.provideMerge(ServerSettings.layer.pipe(Layer.provide(ServerSecretStore.layer))),
316316 Layer.provideMerge(WorkspaceLayerLive),
317317 Layer.provideMerge(ProjectFaviconResolverLayerLive),
318 Layer.provideMerge(RepositoryIdentityResolverLive),
319 Layer.provideMerge(ServerEnvironmentLive),
318 Layer.provideMerge(RepositoryIdentityResolver.layer),
319 Layer.provideMerge(ServerEnvironment.layer),
320320 Layer.provideMerge(AuthLayerLive),
321321 Layer.provideMerge(ServerSecretStore.layer),
322322 Layer.provideMerge(
apps/server/src/serverRuntimeStartup.test.ts+4-1
......@@ -57,7 +57,10 @@ it.effect("enqueueCommand fails queued work when readiness fails", () =>
5757
5858 yield* commandGate.failCommandReady(
5959 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"),
6164 }),
6265 );
6366
apps/server/src/serverRuntimeStartup.ts+10-9
......@@ -30,8 +30,8 @@ import * as ProjectionSnapshotQuery from "./orchestration/Services/ProjectionSna
3030import * as OrchestrationReactor from "./orchestration/Services/OrchestrationReactor.ts";
3131import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts";
3232import * as ServerSettings from "./serverSettings.ts";
33import * as ServerEnvironment from "./environment/Services/ServerEnvironment.ts";
3433import * as AnalyticsService from "./telemetry/AnalyticsService.ts";
34import * as ServerEnvironment from "./environment/ServerEnvironment.ts";
3535import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts";
3636import * as ProviderSessionReaper from "./provider/Services/ProviderSessionReaper.ts";
3737import {
......@@ -44,15 +44,14 @@ import {
4444export class ServerRuntimeStartupError extends Schema.TaggedErrorClass<ServerRuntimeStartupError>()(
4545 "ServerRuntimeStartupError",
4646 {
47 stage: Schema.Literal("command-readiness"),
48 cause: Schema.optional(Schema.Defect()),
47 mode: ServerConfig.RuntimeMode,
48 host: Schema.NullOr(Schema.String),
49 port: Schema.Number,
50 cause: Schema.Defect(),
4951 },
5052) {
5153 override get message(): string {
52 switch (this.stage) {
53 case "command-readiness":
54 return "Server runtime startup failed before command readiness.";
55 }
54 return "Server runtime startup failed before command readiness.";
5655 }
5756}
5857
......@@ -289,7 +288,7 @@ const runStartupPhase = <A, E, R>(phase: string, effect: Effect.Effect<A, E, R>)
289288 Effect.withSpan(`server.startup.${phase}`),
290289 );
291290
292const make = Effect.gen(function* () {
291export const make = Effect.gen(function* () {
293292 const serverConfig = yield* ServerConfig.ServerConfig;
294293 const keybindings = yield* Keybindings.Keybindings;
295294 const orchestrationReactor = yield* OrchestrationReactor.OrchestrationReactor;
......@@ -417,7 +416,9 @@ const make = Effect.gen(function* () {
417416 const startupExit = yield* Effect.exit(startup);
418417 if (Exit.isFailure(startupExit)) {
419418 const error = new ServerRuntimeStartupError({
420 stage: "command-readiness",
419 mode: serverConfig.mode,
420 host: serverConfig.host ?? null,
421 port: serverConfig.port,
421422 cause: startupExit.cause,
422423 });
423424 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";
99import * as Path from "effect/Path";
1010import * as PlatformError from "effect/PlatformError";
1111
12import { ServerConfig } from "../config.ts";
12import * as ServerConfig from "../config.ts";
1313import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
1414import * as VcsProcess from "../vcs/VcsProcess.ts";
1515import * as WorkspaceEntries from "./WorkspaceEntries.ts";
16import { WorkspacePathsLive } from "./Layers/WorkspacePaths.ts";
16import * as WorkspacePaths from "./WorkspacePaths.ts";
1717
1818const TestLayer = Layer.empty.pipe(
19 Layer.provideMerge(WorkspaceEntries.layer.pipe(Layer.provide(WorkspacePathsLive))),
20 Layer.provideMerge(WorkspacePathsLive),
19 Layer.provideMerge(WorkspaceEntries.layer.pipe(Layer.provide(WorkspacePaths.layer))),
20 Layer.provideMerge(WorkspacePaths.layer),
2121 Layer.provideMerge(VcsProcess.layer),
2222 Layer.provide(
23 ServerConfig.layerTest(process.cwd(), {
23 ServerConfig.ServerConfig.layerTest(process.cwd(), {
2424 prefix: "t3-workspace-entries-test-",
2525 }),
2626 ),
......@@ -363,7 +363,10 @@ it.layer(TestLayer, { excludeTestServices: true })("WorkspaceEntries", (it) => {
363363 })
364364 .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 );
367370 }),
368371 );
369372
apps/server/src/workspace/WorkspaceEntries.ts+84-51
......@@ -1,6 +1,6 @@
11// @effect-diagnostics nodeBuiltinImport:off
2import * as NodeFSP from "node:fs/promises";
3import * as NodeOS from "node:os";
2import { readdir } from "node:fs/promises";
3import { homedir } from "node:os";
44
55import * as Context from "effect/Context";
66import * as Effect from "effect/Effect";
......@@ -20,29 +20,72 @@ import type {
2020import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
2121import { isExplicitRelativePath, isWindowsAbsolutePath } from "@t3tools/shared/path";
2222
23import * as WorkspacePaths from "./Services/WorkspacePaths.ts";
23import * as WorkspacePaths from "./WorkspacePaths.ts";
2424import * as WorkspaceSearchIndex from "./WorkspaceSearchIndex.ts";
2525
2626export class WorkspaceEntriesError extends Schema.TaggedErrorClass<WorkspaceEntriesError>()(
2727 "WorkspaceEntriesError",
2828 {
2929 cwd: Schema.String,
30 operation: Schema.String,
31 detail: Schema.String,
32 cause: Schema.optional(Schema.Defect()),
30 operation: Schema.Literals([
31 "workspaceEntries.normalizeWorkspaceRoot",
32 "workspaceEntries.search",
33 "workspaceEntries.list",
34 ]),
35 cause: Schema.Defect(),
3336 },
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>()(
37 "WorkspaceEntriesBrowseError",
43export class WorkspaceEntriesWindowsPathUnsupportedError extends Schema.TaggedErrorClass<WorkspaceEntriesWindowsPathUnsupportedError>()(
44 "WorkspaceEntriesWindowsPathUnsupportedError",
3845 {
3946 cwd: Schema.optional(Schema.String),
4047 partialPath: Schema.String,
41 operation: Schema.String,
42 detail: Schema.String,
43 cause: Schema.optional(Schema.Defect()),
48 platform: Schema.String,
4449 },
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
4790export class WorkspaceEntries extends Context.Service<
4891 WorkspaceEntries,
......@@ -62,46 +105,40 @@ export class WorkspaceEntries extends Context.Service<
62105
63106function expandHomePath(input: string, path: Path.Path): string {
64107 if (input === "~") {
65 return NodeOS.homedir();
108 return homedir();
66109 }
67110 if (input.startsWith("~/") || input.startsWith("~\\")) {
68 return path.join(NodeOS.homedir(), input.slice(2));
111 return path.join(homedir(), input.slice(2));
69112 }
70113 return input;
71114}
72115
73const resolveBrowseTarget = (
116const resolveBrowseTarget = Effect.fn("WorkspaceEntries.resolveBrowseTarget")(function* (
74117 input: FilesystemBrowseInput,
75118 path: Path.Path,
76): Effect.Effect<string, WorkspaceEntriesBrowseError> =>
77 Effect.gen(function* () {
78 const platform = yield* HostProcessPlatform;
79 if (platform !== "win32" && isWindowsAbsolutePath(input.partialPath)) {
80 return yield* new WorkspaceEntriesBrowseError({
81 cwd: input.cwd,
82 partialPath: input.partialPath,
83 operation: "workspaceEntries.resolveBrowseTarget",
84 detail: "Windows-style paths are only supported on Windows.",
85 });
86 }
87
88 if (!isExplicitRelativePath(input.partialPath)) {
89 return path.resolve(expandHomePath(input.partialPath, path));
90 }
91
92 if (!input.cwd) {
93 return yield* new WorkspaceEntriesBrowseError({
94 cwd: input.cwd,
95 partialPath: input.partialPath,
96 operation: "workspaceEntries.resolveBrowseTarget",
97 detail: "Relative filesystem browse paths require a current project.",
98 });
99 }
100
101 return path.resolve(expandHomePath(input.cwd, path), input.partialPath);
102 });
119): Effect.fn.Return<string, WorkspaceEntriesBrowseError> {
120 const platform = yield* HostProcessPlatform;
121 if (platform !== "win32" && isWindowsAbsolutePath(input.partialPath)) {
122 return yield* new WorkspaceEntriesWindowsPathUnsupportedError({
123 cwd: input.cwd,
124 partialPath: input.partialPath,
125 platform,
126 });
127 }
128
129 if (!isExplicitRelativePath(input.partialPath)) {
130 return path.resolve(expandHomePath(input.partialPath, path));
131 }
132
133 if (!input.cwd) {
134 return yield* new WorkspaceEntriesCurrentProjectRequiredError({
135 partialPath: input.partialPath,
136 });
137 }
138 return path.resolve(expandHomePath(input.cwd, path), input.partialPath);
139});
103140
104const make = Effect.gen(function* () {
141export const make = Effect.gen(function* () {
105142 const path = yield* Path.Path;
106143 const workspacePaths = yield* WorkspacePaths.WorkspacePaths;
107144 const workspaceSearchIndexes = yield* WorkspaceSearchIndex.WorkspaceSearchIndexMap;
......@@ -115,7 +152,6 @@ const make = Effect.gen(function* () {
115152 new WorkspaceEntriesError({
116153 cwd,
117154 operation: "workspaceEntries.normalizeWorkspaceRoot",
118 detail: cause.message,
119155 cause,
120156 }),
121157 ),
......@@ -156,13 +192,12 @@ const make = Effect.gen(function* () {
156192 const prefix = endsWithSeparator ? "" : path.basename(resolvedInputPath);
157193
158194 const dirents = yield* Effect.tryPromise({
159 try: () => NodeFSP.readdir(parentPath, { withFileTypes: true }),
195 try: () => readdir(parentPath, { withFileTypes: true }),
160196 catch: (cause) =>
161 new WorkspaceEntriesBrowseError({
197 new WorkspaceEntriesReadDirectoryError({
162198 cwd: input.cwd,
163199 partialPath: input.partialPath,
164 operation: "workspaceEntries.browse.readDirectory",
165 detail: `Unable to browse '${parentPath}': ${cause instanceof Error ? cause.message : String(cause)}`,
200 parentPath,
166201 cause,
167202 }),
168203 }).pipe(
......@@ -215,7 +250,6 @@ const make = Effect.gen(function* () {
215250 new WorkspaceEntriesError({
216251 cwd: input.cwd,
217252 operation: "workspaceEntries.search",
218 detail: cause.message,
219253 cause,
220254 }),
221255 ),
......@@ -236,7 +270,6 @@ const make = Effect.gen(function* () {
236270 new WorkspaceEntriesError({
237271 cwd: input.cwd,
238272 operation: "workspaceEntries.list",
239 detail: cause.message,
240273 cause,
241274 }),
242275 ),
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* (
182182 );
183183});
184184
185const makeWorkspaceSearchIndex = (cwd: string) =>
186 Effect.acquireRelease(createFinder(cwd), (finder) => Effect.sync(() => finder.destroy())).pipe(
187 Effect.tap((finder) => waitForScan(cwd, finder)),
188 Effect.map((finder) => {
189 const runMixedSearch = Effect.fn("WorkspaceSearchIndex.runMixedSearch")(function* (
190 query: string,
191 pageSize: number,
192 ) {
193 const result = yield* Effect.sync(() => finder.mixedSearch(query, { pageSize }));
194 if (!result.ok) {
195 return yield* new WorkspaceSearchIndexSearchFailed({ cwd, reason: result.error });
196 }
197 return result.value;
198 });
199
200 const refresh: WorkspaceSearchIndex["Service"]["refresh"] = Effect.fn(
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 );
185export const make = Effect.fn("WorkspaceSearchIndex.make")(function* (cwd: string) {
186 const finder = yield* Effect.acquireRelease(createFinder(cwd), (finder) =>
187 Effect.sync(() => finder.destroy()),
188 );
189 yield* waitForScan(cwd, finder);
190
191 const runMixedSearch = Effect.fn("WorkspaceSearchIndex.runMixedSearch")(function* (
192 query: string,
193 pageSize: number,
194 ) {
195 const result = yield* Effect.sync(() => finder.mixedSearch(query, { pageSize }));
196 if (!result.ok) {
197 return yield* new WorkspaceSearchIndexSearchFailed({ cwd, reason: result.error });
198 }
199 return result.value;
200 });
224201
225 const search: WorkspaceSearchIndex["Service"]["search"] = Effect.fn(
226 "WorkspaceSearchIndex.search",
227 )(function* (query, limit) {
228 const result = yield* runMixedSearch(query, Math.max(1, limit + 1));
229 return mapMixedSearchResult(result, limit);
230 });
202 const refresh: WorkspaceSearchIndex["Service"]["refresh"] = Effect.fn(
203 "WorkspaceSearchIndex.refresh",
204 )(function* () {
205 const result = yield* Effect.sync(() => finder.scanFiles());
206 if (!result.ok) {
207 return yield* new WorkspaceSearchIndexRefreshFailed({ cwd, reason: result.error });
208 }
209 yield* waitForScan(cwd, finder);
210 });
231211
232 return WorkspaceSearchIndex.of({ list, refresh, search });
233 }),
212 const list: WorkspaceSearchIndex["Service"]["list"] = Effect.fn("WorkspaceSearchIndex.list")(
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 },
234225 );
235226
236const workspaceSearchIndexLayer = (cwd: string) =>
237 Layer.effect(WorkspaceSearchIndex, makeWorkspaceSearchIndex(cwd));
227 const search: WorkspaceSearchIndex["Service"]["search"] = Effect.fn(
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
239244export class WorkspaceSearchIndexMap extends LayerMap.Service<WorkspaceSearchIndexMap>()(
240245 "t3/workspace/WorkspaceSearchIndexMap",
241246 {
242 lookup: workspaceSearchIndexLayer,
247 lookup: layer,
243248 idleTimeToLive: WORKSPACE_INDEX_IDLE_TTL,
244249 },
245250) {}
apps/server/src/ws.ts+9-9
......@@ -79,15 +79,15 @@ import * as PreviewManager from "./preview/Manager.ts";
7979import { issueAssetUrl } from "./assets/AssetAccess.ts";
8080import * as PortScanner from "./preview/PortScanner.ts";
8181import * as WorkspaceEntries from "./workspace/WorkspaceEntries.ts";
82import * as WorkspaceFileSystem from "./workspace/Services/WorkspaceFileSystem.ts";
83import * as WorkspacePaths from "./workspace/Services/WorkspacePaths.ts";
82import * as WorkspaceFileSystem from "./workspace/WorkspaceFileSystem.ts";
83import * as WorkspacePaths from "./workspace/WorkspacePaths.ts";
8484import * as VcsStatusBroadcaster from "./vcs/VcsStatusBroadcaster.ts";
8585import * as VcsProvisioningService from "./vcs/VcsProvisioningService.ts";
8686import * as GitWorkflowService from "./git/GitWorkflowService.ts";
8787import * as ReviewService from "./review/ReviewService.ts";
88import * as ProjectSetupScriptRunner from "./project/Services/ProjectSetupScriptRunner.ts";
89import * as RepositoryIdentityResolver from "./project/Services/RepositoryIdentityResolver.ts";
90import * as ServerEnvironment from "./environment/Services/ServerEnvironment.ts";
88import * as ProjectSetupScriptRunner from "./project/ProjectSetupScriptRunner.ts";
89import * as RepositoryIdentityResolver from "./project/RepositoryIdentityResolver.ts";
90import * as ServerEnvironment from "./environment/ServerEnvironment.ts";
9191import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts";
9292import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts";
9393import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts";
......@@ -1190,7 +1190,7 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) =>
11901190 Effect.mapError(
11911191 (cause) =>
11921192 new ProjectSearchEntriesError({
1193 message: `Failed to search workspace entries: ${cause.detail}`,
1193 message: "Failed to search workspace entries.",
11941194 cause,
11951195 }),
11961196 ),
......@@ -1204,7 +1204,7 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) =>
12041204 Effect.mapError(
12051205 (cause) =>
12061206 new ProjectListEntriesError({
1207 message: `Failed to list workspace entries: ${cause.detail}`,
1207 message: "Failed to list workspace entries.",
12081208 cause,
12091209 }),
12101210 ),
......@@ -1218,7 +1218,7 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) =>
12181218 Effect.mapError((cause) => {
12191219 const message = isWorkspacePathOutsideRootError(cause)
12201220 ? "Workspace file path must stay within the project root."
1221 : `Failed to read workspace file: ${cause.detail}`;
1221 : "Failed to read workspace file.";
12221222 return new ProjectReadFileError({ message, cause });
12231223 }),
12241224 ),
......@@ -1251,7 +1251,7 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) =>
12511251 Effect.mapError(
12521252 (cause) =>
12531253 new FilesystemBrowseError({
1254 message: cause.detail,
1254 message: "Failed to browse the filesystem.",
12551255 cause,
12561256 }),
12571257 ),