authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-03-31 00:08:51-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-03-31 00:08:51-07:00
log2d03847ccd53ffcd5ba74301ce85aef2afe58ad5
tree980cd38ae17de7b01d05f370b736997330cad6ce
parentd303ac39cbd97a94489b460f62eac153ad1857ad
signature Signed by PGP key B5690EEEBB952194

Extract project/workspace related functionality to Effect services (#1524)


26 files changed, 2077 insertions(+), 1140 deletions(-)

apps/server/integration/OrchestrationEngineHarness.integration.ts+7
...@@ -68,6 +68,7 @@ import {...@@ -68,6 +68,7 @@ import {
68 type TestProviderAdapterHarness,68 type TestProviderAdapterHarness,
69} from "./TestProviderAdapter.integration.ts";69} from "./TestProviderAdapter.integration.ts";
70import { deriveServerPaths, ServerConfig } from "../src/config.ts";70import { deriveServerPaths, ServerConfig } from "../src/config.ts";
71import { WorkspaceEntriesLive } from "../src/workspace/Layers/WorkspaceEntries.ts";
7172
72function runGit(cwd: string, args: ReadonlyArray<string>) {73function runGit(cwd: string, args: ReadonlyArray<string>) {
73 return execFileSync("git", args, {74 return execFileSync("git", args, {
...@@ -317,6 +318,12 @@ export const makeOrchestrationIntegrationHarness = (...@@ -317,6 +318,12 @@ export const makeOrchestrationIntegrationHarness = (
317 );318 );
318 const checkpointReactorLayer = CheckpointReactorLive.pipe(319 const checkpointReactorLayer = CheckpointReactorLive.pipe(
319 Layer.provideMerge(runtimeServicesLayer),320 Layer.provideMerge(runtimeServicesLayer),
321 Layer.provideMerge(
322 WorkspaceEntriesLive.pipe(
323 Layer.provideMerge(gitCoreLayer),
324 Layer.provide(NodeServices.layer),
325 ),
326 ),
320 );327 );
321 const orchestrationReactorLayer = OrchestrationReactorLive.pipe(328 const orchestrationReactorLayer = OrchestrationReactorLive.pipe(
322 Layer.provideMerge(runtimeIngestionLayer),329 Layer.provideMerge(runtimeIngestionLayer),
apps/server/src/git/Layers/GitCore.test.ts+82-8
...@@ -138,6 +138,13 @@ function buildLargeText(lineCount = 20_000): string {...@@ -138,6 +138,13 @@ function buildLargeText(lineCount = 20_000): string {
138 .concat("\n");138 .concat("\n");
139}139}
140140
141function splitNullSeparatedPaths(input: string): string[] {
142 return input
143 .split("\0")
144 .map((value) => value.trim())
145 .filter((value) => value.length > 0);
146}
147
141// ── Tests ──148// ── Tests ──
142149
143it.layer(TestLayer)("git integration", (it) => {150it.layer(TestLayer)("git integration", (it) => {
...@@ -181,6 +188,55 @@ it.layer(TestLayer)("git integration", (it) => {...@@ -181,6 +188,55 @@ it.layer(TestLayer)("git integration", (it) => {
181 );188 );
182 });189 });
183190
191 describe("workspace helpers", () => {
192 it.effect("filterIgnoredPaths chunks large path lists and preserves kept paths", () =>
193 Effect.gen(function* () {
194 const cwd = "/virtual/repo";
195 const relativePaths = Array.from({ length: 340 }, (_, index) => {
196 const prefix = index % 3 === 0 ? "ignored" : "kept";
197 return `${prefix}/segment-${String(index).padStart(4, "0")}/${"x".repeat(900)}.ts`;
198 });
199 const expectedPaths = relativePaths.filter(
200 (relativePath) => !relativePath.startsWith("ignored/"),
201 );
202
203 const seenChunks: string[][] = [];
204 const core = yield* makeIsolatedGitCore((input) => {
205 if (input.args.join(" ") !== "check-ignore --no-index -z --stdin") {
206 return Effect.fail(
207 new GitCommandError({
208 operation: input.operation,
209 command: `git ${input.args.join(" ")}`,
210 cwd: input.cwd,
211 detail: "unexpected git command in chunking test",
212 }),
213 );
214 }
215
216 const chunkPaths = splitNullSeparatedPaths(input.stdin ?? "");
217 seenChunks.push(chunkPaths);
218 const ignoredPaths = chunkPaths.filter((relativePath) =>
219 relativePath.startsWith("ignored/"),
220 );
221
222 return Effect.succeed({
223 code: ignoredPaths.length > 0 ? 0 : 1,
224 stdout: ignoredPaths.length > 0 ? `${ignoredPaths.join("\0")}\0` : "",
225 stderr: "",
226 stdoutTruncated: false,
227 stderrTruncated: false,
228 });
229 });
230
231 const result = yield* core.filterIgnoredPaths(cwd, relativePaths);
232
233 expect(seenChunks.length).toBeGreaterThan(1);
234 expect(seenChunks.flat()).toEqual(relativePaths);
235 expect(result).toEqual(expectedPaths);
236 }),
237 );
238 });
239
184 // ── listGitBranches ──240 // ── listGitBranches ──
185241
186 describe("listGitBranches", () => {242 describe("listGitBranches", () => {
...@@ -457,12 +513,18 @@ it.layer(TestLayer)("git integration", (it) => {...@@ -457,12 +513,18 @@ it.layer(TestLayer)("git integration", (it) => {
457 yield* (yield* GitCore).checkoutBranch({ cwd: source, branch: featureBranch });513 yield* (yield* GitCore).checkoutBranch({ cwd: source, branch: featureBranch });
458 const core = yield* GitCore;514 const core = yield* GitCore;
459 yield* Effect.promise(() =>515 yield* Effect.promise(() =>
460 vi.waitFor(async () => {516 vi.waitFor(
461 const details = await runPromise(core.statusDetails(source));517 async () => {
462 expect(details.branch).toBe(featureBranch);518 const details = await runPromise(core.statusDetails(source));
463 expect(details.aheadCount).toBe(0);519 expect(details.branch).toBe(featureBranch);
464 expect(details.behindCount).toBe(1);520 expect(details.aheadCount).toBe(0);
465 }),521 expect(details.behindCount).toBe(1);
522 },
523 {
524 timeout: 10_000,
525 interval: 100,
526 },
527 ),
466 );528 );
467 }),529 }),
468 );530 );
...@@ -541,7 +603,13 @@ it.layer(TestLayer)("git integration", (it) => {...@@ -541,7 +603,13 @@ it.layer(TestLayer)("git integration", (it) => {
541 const core = yield* makeIsolatedGitCore((input) => {603 const core = yield* makeIsolatedGitCore((input) => {
542 if (input.args[0] === "fetch") {604 if (input.args[0] === "fetch") {
543 fetchArgs = [...input.args];605 fetchArgs = [...input.args];
544 return Effect.succeed({ code: 0, stdout: "", stderr: "" });606 return Effect.succeed({
607 code: 0,
608 stdout: "",
609 stderr: "",
610 stdoutTruncated: false,
611 stderrTruncated: false,
612 });
545 }613 }
546 return realGitCore.execute(input);614 return realGitCore.execute(input);
547 });615 });
...@@ -594,7 +662,13 @@ it.layer(TestLayer)("git integration", (it) => {...@@ -594,7 +662,13 @@ it.layer(TestLayer)("git integration", (it) => {
594 if (input.args[0] === "fetch") {662 if (input.args[0] === "fetch") {
595 fetchStarted = true;663 fetchStarted = true;
596 return Effect.promise(() =>664 return Effect.promise(() =>
597 waitForReleasePromise.then(() => ({ code: 0, stdout: "", stderr: "" })),665 waitForReleasePromise.then(() => ({
666 code: 0,
667 stdout: "",
668 stderr: "",
669 stdoutTruncated: false,
670 stderrTruncated: false,
671 })),
598 );672 );
599 }673 }
600 return realGitCore.execute(input);674 return realGitCore.execute(input);
apps/server/src/git/Layers/GitCore.ts+153-7
...@@ -37,6 +37,8 @@ const PREPARED_COMMIT_PATCH_MAX_OUTPUT_BYTES = 49_000;...@@ -37,6 +37,8 @@ const PREPARED_COMMIT_PATCH_MAX_OUTPUT_BYTES = 49_000;
37const RANGE_COMMIT_SUMMARY_MAX_OUTPUT_BYTES = 19_000;37const RANGE_COMMIT_SUMMARY_MAX_OUTPUT_BYTES = 19_000;
38const RANGE_DIFF_SUMMARY_MAX_OUTPUT_BYTES = 19_000;38const RANGE_DIFF_SUMMARY_MAX_OUTPUT_BYTES = 19_000;
39const RANGE_DIFF_PATCH_MAX_OUTPUT_BYTES = 59_000;39const RANGE_DIFF_PATCH_MAX_OUTPUT_BYTES = 59_000;
40const WORKSPACE_FILES_MAX_OUTPUT_BYTES = 16 * 1024 * 1024;
41const GIT_CHECK_IGNORE_MAX_STDIN_BYTES = 256 * 1024;
40const STATUS_UPSTREAM_REFRESH_INTERVAL = Duration.seconds(15);42const STATUS_UPSTREAM_REFRESH_INTERVAL = Duration.seconds(15);
41const STATUS_UPSTREAM_REFRESH_TIMEOUT = Duration.seconds(5);43const STATUS_UPSTREAM_REFRESH_TIMEOUT = Duration.seconds(5);
42const STATUS_UPSTREAM_REFRESH_CACHE_CAPACITY = 2_048;44const STATUS_UPSTREAM_REFRESH_CACHE_CAPACITY = 2_048;
...@@ -55,6 +57,7 @@ class StatusUpstreamRefreshCacheKey extends Data.Class<{...@@ -55,6 +57,7 @@ class StatusUpstreamRefreshCacheKey extends Data.Class<{
55}> {}57}> {}
5658
57interface ExecuteGitOptions {59interface ExecuteGitOptions {
60 stdin?: string | undefined;
58 timeoutMs?: number | undefined;61 timeoutMs?: number | undefined;
59 allowNonZeroExit?: boolean | undefined;62 allowNonZeroExit?: boolean | undefined;
60 fallbackErrorMessage?: string | undefined;63 fallbackErrorMessage?: string | undefined;
...@@ -96,6 +99,47 @@ function parseNumstatEntries(...@@ -96,6 +99,47 @@ function parseNumstatEntries(
96 return entries;99 return entries;
97}100}
98101
102function splitNullSeparatedPaths(input: string, truncated: boolean): string[] {
103 const parts = input.split("\0");
104 if (parts.length === 0) return [];
105
106 if (truncated && parts[parts.length - 1]?.length) {
107 parts.pop();
108 }
109
110 return parts.filter((value) => value.length > 0);
111}
112
113function chunkPathsForGitCheckIgnore(relativePaths: readonly string[]): string[][] {
114 const chunks: string[][] = [];
115 let chunk: string[] = [];
116 let chunkBytes = 0;
117
118 for (const relativePath of relativePaths) {
119 const relativePathBytes = Buffer.byteLength(relativePath) + 1;
120 if (chunk.length > 0 && chunkBytes + relativePathBytes > GIT_CHECK_IGNORE_MAX_STDIN_BYTES) {
121 chunks.push(chunk);
122 chunk = [];
123 chunkBytes = 0;
124 }
125
126 chunk.push(relativePath);
127 chunkBytes += relativePathBytes;
128
129 if (chunkBytes >= GIT_CHECK_IGNORE_MAX_STDIN_BYTES) {
130 chunks.push(chunk);
131 chunk = [];
132 chunkBytes = 0;
133 }
134 }
135
136 if (chunk.length > 0) {
137 chunks.push(chunk);
138 }
139
140 return chunks;
141}
142
99function parsePorcelainPath(line: string): string | null {143function parsePorcelainPath(line: string): string | null {
100 if (line.startsWith("? ") || line.startsWith("! ")) {144 if (line.startsWith("? ") || line.startsWith("! ")) {
101 const simple = line.slice(2).trim();145 const simple = line.slice(2).trim();
...@@ -445,7 +489,7 @@ const collectOutput = Effect.fn("collectOutput")(function* <E>(...@@ -445,7 +489,7 @@ const collectOutput = Effect.fn("collectOutput")(function* <E>(
445 maxOutputBytes: number,489 maxOutputBytes: number,
446 truncateOutputAtMaxBytes: boolean,490 truncateOutputAtMaxBytes: boolean,
447 onLine: ((line: string) => Effect.Effect<void, never>) | undefined,491 onLine: ((line: string) => Effect.Effect<void, never>) | undefined,
448): Effect.fn.Return<string, GitCommandError> {492): Effect.fn.Return<{ readonly text: string; readonly truncated: boolean }, GitCommandError> {
449 const decoder = new TextDecoder();493 const decoder = new TextDecoder();
450 let bytes = 0;494 let bytes = 0;
451 let text = "";495 let text = "";
...@@ -507,7 +551,10 @@ const collectOutput = Effect.fn("collectOutput")(function* <E>(...@@ -507,7 +551,10 @@ const collectOutput = Effect.fn("collectOutput")(function* <E>(
507 text += remainder;551 text += remainder;
508 lineBuffer += remainder;552 lineBuffer += remainder;
509 yield* emitCompleteLines(true);553 yield* emitCompleteLines(true);
510 return truncated ? `${text}${OUTPUT_TRUNCATED_MARKER}` : text;554 return {
555 text,
556 truncated,
557 };
511});558});
512559
513export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: {560export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: {
...@@ -571,13 +618,18 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: {...@@ -571,13 +618,18 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: {
571 Effect.map((value) => Number(value)),618 Effect.map((value) => Number(value)),
572 Effect.mapError(toGitCommandError(commandInput, "failed to report exit code.")),619 Effect.mapError(toGitCommandError(commandInput, "failed to report exit code.")),
573 ),620 ),
621 input.stdin === undefined
622 ? Effect.void
623 : Stream.run(Stream.encodeText(Stream.make(input.stdin)), child.stdin).pipe(
624 Effect.mapError(toGitCommandError(commandInput, "failed to write stdin.")),
625 ),
574 ],626 ],
575 { concurrency: "unbounded" },627 { concurrency: "unbounded" },
576 );628 ).pipe(Effect.map(([stdout, stderr, exitCode]) => [stdout, stderr, exitCode] as const));
577 yield* trace2Monitor.flush;629 yield* trace2Monitor.flush;
578630
579 if (!input.allowNonZeroExit && exitCode !== 0) {631 if (!input.allowNonZeroExit && exitCode !== 0) {
580 const trimmedStderr = stderr.trim();632 const trimmedStderr = stderr.text.trim();
581 return yield* new GitCommandError({633 return yield* new GitCommandError({
582 operation: commandInput.operation,634 operation: commandInput.operation,
583 command: quoteGitCommand(commandInput.args),635 command: quoteGitCommand(commandInput.args),
...@@ -589,7 +641,13 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: {...@@ -589,7 +641,13 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: {
589 });641 });
590 }642 }
591643
592 return { code: exitCode, stdout, stderr } satisfies ExecuteGitResult;644 return {
645 code: exitCode,
646 stdout: stdout.text,
647 stderr: stderr.text,
648 stdoutTruncated: stdout.truncated,
649 stderrTruncated: stderr.truncated,
650 } satisfies ExecuteGitResult;
593 });651 });
594652
595 return yield* runGitCommand().pipe(653 return yield* runGitCommand().pipe(
...@@ -618,11 +676,12 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: {...@@ -618,11 +676,12 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: {
618 cwd: string,676 cwd: string,
619 args: readonly string[],677 args: readonly string[],
620 options: ExecuteGitOptions = {},678 options: ExecuteGitOptions = {},
621 ): Effect.Effect<{ code: number; stdout: string; stderr: string }, GitCommandError> =>679 ): Effect.Effect<ExecuteGitResult, GitCommandError> =>
622 execute({680 execute({
623 operation,681 operation,
624 cwd,682 cwd,
625 args,683 args,
684 ...(options.stdin !== undefined ? { stdin: options.stdin } : {}),
626 allowNonZeroExit: true,685 allowNonZeroExit: true,
627 ...(options.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}),686 ...(options.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}),
628 ...(options.maxOutputBytes !== undefined ? { maxOutputBytes: options.maxOutputBytes } : {}),687 ...(options.maxOutputBytes !== undefined ? { maxOutputBytes: options.maxOutputBytes } : {}),
...@@ -679,7 +738,11 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: {...@@ -679,7 +738,11 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: {
679 args: readonly string[],738 args: readonly string[],
680 options: ExecuteGitOptions = {},739 options: ExecuteGitOptions = {},
681 ): Effect.Effect<string, GitCommandError> =>740 ): Effect.Effect<string, GitCommandError> =>
682 executeGit(operation, cwd, args, options).pipe(Effect.map((result) => result.stdout));741 executeGit(operation, cwd, args, options).pipe(
742 Effect.map((result) =>
743 result.stdoutTruncated ? `${result.stdout}${OUTPUT_TRUNCATED_MARKER}` : result.stdout,
744 ),
745 );
683746
684 const branchExists = (cwd: string, branch: string): Effect.Effect<boolean, GitCommandError> =>747 const branchExists = (cwd: string, branch: string): Effect.Effect<boolean, GitCommandError> =>
685 executeGit(748 executeGit(
...@@ -1416,6 +1479,86 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: {...@@ -1416,6 +1479,86 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: {
1416 Effect.map((trimmed) => (trimmed.length > 0 ? trimmed : null)),1479 Effect.map((trimmed) => (trimmed.length > 0 ? trimmed : null)),
1417 );1480 );
14181481
1482 const isInsideWorkTree: GitCoreShape["isInsideWorkTree"] = (cwd) =>
1483 executeGit("GitCore.isInsideWorkTree", cwd, ["rev-parse", "--is-inside-work-tree"], {
1484 allowNonZeroExit: true,
1485 timeoutMs: 5_000,
1486 maxOutputBytes: 4_096,
1487 }).pipe(Effect.map((result) => result.code === 0 && result.stdout.trim() === "true"));
1488
1489 const listWorkspaceFiles: GitCoreShape["listWorkspaceFiles"] = (cwd) =>
1490 executeGit(
1491 "GitCore.listWorkspaceFiles",
1492 cwd,
1493 ["ls-files", "--cached", "--others", "--exclude-standard", "-z"],
1494 {
1495 allowNonZeroExit: true,
1496 timeoutMs: 20_000,
1497 maxOutputBytes: WORKSPACE_FILES_MAX_OUTPUT_BYTES,
1498 truncateOutputAtMaxBytes: true,
1499 },
1500 ).pipe(
1501 Effect.flatMap((result) =>
1502 result.code === 0
1503 ? Effect.succeed({
1504 paths: splitNullSeparatedPaths(result.stdout, result.stdoutTruncated),
1505 truncated: result.stdoutTruncated,
1506 })
1507 : Effect.fail(
1508 createGitCommandError(
1509 "GitCore.listWorkspaceFiles",
1510 cwd,
1511 ["ls-files", "--cached", "--others", "--exclude-standard", "-z"],
1512 result.stderr.trim().length > 0 ? result.stderr.trim() : "git ls-files failed",
1513 ),
1514 ),
1515 ),
1516 );
1517
1518 const filterIgnoredPaths: GitCoreShape["filterIgnoredPaths"] = (cwd, relativePaths) =>
1519 Effect.gen(function* () {
1520 if (relativePaths.length === 0) {
1521 return relativePaths;
1522 }
1523
1524 const ignoredPaths = new Set<string>();
1525 const chunks = chunkPathsForGitCheckIgnore(relativePaths);
1526
1527 for (const chunk of chunks) {
1528 const result = yield* executeGit(
1529 "GitCore.filterIgnoredPaths",
1530 cwd,
1531 ["check-ignore", "--no-index", "-z", "--stdin"],
1532 {
1533 stdin: `${chunk.join("\0")}\0`,
1534 allowNonZeroExit: true,
1535 timeoutMs: 20_000,
1536 maxOutputBytes: WORKSPACE_FILES_MAX_OUTPUT_BYTES,
1537 truncateOutputAtMaxBytes: true,
1538 },
1539 );
1540
1541 if (result.code !== 0 && result.code !== 1) {
1542 return yield* createGitCommandError(
1543 "GitCore.filterIgnoredPaths",
1544 cwd,
1545 ["check-ignore", "--no-index", "-z", "--stdin"],
1546 result.stderr.trim().length > 0 ? result.stderr.trim() : "git check-ignore failed",
1547 );
1548 }
1549
1550 for (const ignoredPath of splitNullSeparatedPaths(result.stdout, result.stdoutTruncated)) {
1551 ignoredPaths.add(ignoredPath);
1552 }
1553 }
1554
1555 if (ignoredPaths.size === 0) {
1556 return relativePaths;
1557 }
1558
1559 return relativePaths.filter((relativePath) => !ignoredPaths.has(relativePath));
1560 });
1561
1419 const listBranches: GitCoreShape["listBranches"] = Effect.fn("listBranches")(function* (input) {1562 const listBranches: GitCoreShape["listBranches"] = Effect.fn("listBranches")(function* (input) {
1420 const branchRecencyPromise = readBranchRecency(input.cwd).pipe(1563 const branchRecencyPromise = readBranchRecency(input.cwd).pipe(
1421 Effect.catch(() => Effect.succeed(new Map<string, number>())),1564 Effect.catch(() => Effect.succeed(new Map<string, number>())),
...@@ -1834,6 +1977,9 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: {...@@ -1834,6 +1977,9 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: {
1834 pullCurrentBranch,1977 pullCurrentBranch,
1835 readRangeContext,1978 readRangeContext,
1836 readConfigValue,1979 readConfigValue,
1980 isInsideWorkTree,
1981 listWorkspaceFiles,
1982 filterIgnoredPaths,
1837 listBranches,1983 listBranches,
1838 createWorktree,1984 createWorktree,
1839 fetchPullRequestBranch,1985 fetchPullRequestBranch,
apps/server/src/git/Services/GitCore.ts+28
...@@ -28,6 +28,7 @@ export interface ExecuteGitInput {...@@ -28,6 +28,7 @@ export interface ExecuteGitInput {
28 readonly operation: string;28 readonly operation: string;
29 readonly cwd: string;29 readonly cwd: string;
30 readonly args: ReadonlyArray<string>;30 readonly args: ReadonlyArray<string>;
31 readonly stdin?: string;
31 readonly env?: NodeJS.ProcessEnv;32 readonly env?: NodeJS.ProcessEnv;
32 readonly allowNonZeroExit?: boolean;33 readonly allowNonZeroExit?: boolean;
33 readonly timeoutMs?: number;34 readonly timeoutMs?: number;
...@@ -40,6 +41,8 @@ export interface ExecuteGitResult {...@@ -40,6 +41,8 @@ export interface ExecuteGitResult {
40 readonly code: number;41 readonly code: number;
41 readonly stdout: string;42 readonly stdout: string;
42 readonly stderr: string;43 readonly stderr: string;
44 readonly stdoutTruncated: boolean;
45 readonly stderrTruncated: boolean;
43}46}
4447
45export interface GitStatusDetails extends Omit<GitStatusResult, "pr"> {48export interface GitStatusDetails extends Omit<GitStatusResult, "pr"> {
...@@ -93,6 +96,11 @@ export interface GitRangeContext {...@@ -93,6 +96,11 @@ export interface GitRangeContext {
93 diffPatch: string;96 diffPatch: string;
94}97}
9598
99export interface GitListWorkspaceFilesResult {
100 readonly paths: ReadonlyArray<string>;
101 readonly truncated: boolean;
102}
103
96export interface GitRenameBranchInput {104export interface GitRenameBranchInput {
97 cwd: string;105 cwd: string;
98 oldBranch: string;106 oldBranch: string;
...@@ -190,6 +198,26 @@ export interface GitCoreShape {...@@ -190,6 +198,26 @@ export interface GitCoreShape {
190 key: string,198 key: string,
191 ) => Effect.Effect<string | null, GitCommandError>;199 ) => Effect.Effect<string | null, GitCommandError>;
192200
201 /**
202 * Determine whether the provided cwd is inside a git work tree.
203 */
204 readonly isInsideWorkTree: (cwd: string) => Effect.Effect<boolean, GitCommandError>;
205
206 /**
207 * List tracked and untracked workspace file paths relative to cwd.
208 */
209 readonly listWorkspaceFiles: (
210 cwd: string,
211 ) => Effect.Effect<GitListWorkspaceFilesResult, GitCommandError>;
212
213 /**
214 * Remove gitignored paths from a relative path list.
215 */
216 readonly filterIgnoredPaths: (
217 cwd: string,
218 relativePaths: ReadonlyArray<string>,
219 ) => Effect.Effect<ReadonlyArray<string>, GitCommandError>;
220
193 /**221 /**
194 * List local + remote branches and branch metadata.222 * List local + remote branches and branch metadata.
195 */223 */
apps/server/src/orchestration/Layers/CheckpointReactor.test.ts+4-1
...@@ -38,6 +38,7 @@ import {...@@ -38,6 +38,7 @@ import {
38} from "../../provider/Services/ProviderService.ts";38} from "../../provider/Services/ProviderService.ts";
39import { checkpointRefForThreadTurn } from "../../checkpointing/Utils.ts";39import { checkpointRefForThreadTurn } from "../../checkpointing/Utils.ts";
40import { ServerConfig } from "../../config.ts";40import { ServerConfig } from "../../config.ts";
41import { WorkspaceEntriesLive } from "../../workspace/Layers/WorkspaceEntries.ts";
4142
42const asProjectId = (value: string): ProjectId => ProjectId.makeUnsafe(value);43const asProjectId = (value: string): ProjectId => ProjectId.makeUnsafe(value);
43const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value);44const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value);
...@@ -261,7 +262,9 @@ describe("CheckpointReactor", () => {...@@ -261,7 +262,9 @@ describe("CheckpointReactor", () => {
261 Layer.provideMerge(orchestrationLayer),262 Layer.provideMerge(orchestrationLayer),
262 Layer.provideMerge(RuntimeReceiptBusLive),263 Layer.provideMerge(RuntimeReceiptBusLive),
263 Layer.provideMerge(Layer.succeed(ProviderService, provider.service)),264 Layer.provideMerge(Layer.succeed(ProviderService, provider.service)),
264 Layer.provideMerge(CheckpointStoreLive.pipe(Layer.provide(GitCoreLive))),265 Layer.provideMerge(CheckpointStoreLive),
266 Layer.provideMerge(WorkspaceEntriesLive),
267 Layer.provideMerge(GitCoreLive),
265 Layer.provideMerge(ServerConfigLayer),268 Layer.provideMerge(ServerConfigLayer),
266 Layer.provideMerge(NodeServices.layer),269 Layer.provideMerge(NodeServices.layer),
267 );270 );
apps/server/src/orchestration/Layers/CheckpointReactor.ts+4-3
...@@ -16,7 +16,6 @@ import {...@@ -16,7 +16,6 @@ import {
16 checkpointRefForThreadTurn,16 checkpointRefForThreadTurn,
17 resolveThreadWorkspaceCwd,17 resolveThreadWorkspaceCwd,
18} from "../../checkpointing/Utils.ts";18} from "../../checkpointing/Utils.ts";
19import { clearWorkspaceIndexCache } from "../../workspaceEntries.ts";
20import { CheckpointStore } from "../../checkpointing/Services/CheckpointStore.ts";19import { CheckpointStore } from "../../checkpointing/Services/CheckpointStore.ts";
21import { ProviderService } from "../../provider/Services/ProviderService.ts";20import { ProviderService } from "../../provider/Services/ProviderService.ts";
22import { CheckpointReactor, type CheckpointReactorShape } from "../Services/CheckpointReactor.ts";21import { CheckpointReactor, type CheckpointReactorShape } from "../Services/CheckpointReactor.ts";
...@@ -25,6 +24,7 @@ import { RuntimeReceiptBus } from "../Services/RuntimeReceiptBus.ts";...@@ -25,6 +24,7 @@ import { RuntimeReceiptBus } from "../Services/RuntimeReceiptBus.ts";
25import { CheckpointStoreError } from "../../checkpointing/Errors.ts";24import { CheckpointStoreError } from "../../checkpointing/Errors.ts";
26import { OrchestrationDispatchError } from "../Errors.ts";25import { OrchestrationDispatchError } from "../Errors.ts";
27import { isGitRepository } from "../../git/Utils.ts";26import { isGitRepository } from "../../git/Utils.ts";
27import { WorkspaceEntries } from "../../workspace/Services/WorkspaceEntries.ts";
2828
29type ReactorInput =29type ReactorInput =
30 | {30 | {
...@@ -68,6 +68,7 @@ const make = Effect.gen(function* () {...@@ -68,6 +68,7 @@ const make = Effect.gen(function* () {
68 const providerService = yield* ProviderService;68 const providerService = yield* ProviderService;
69 const checkpointStore = yield* CheckpointStore;69 const checkpointStore = yield* CheckpointStore;
70 const receiptBus = yield* RuntimeReceiptBus;70 const receiptBus = yield* RuntimeReceiptBus;
71 const workspaceEntries = yield* WorkspaceEntries;
7172
72 const appendRevertFailureActivity = (input: {73 const appendRevertFailureActivity = (input: {
73 readonly threadId: ThreadId;74 readonly threadId: ThreadId;
...@@ -226,7 +227,7 @@ const make = Effect.gen(function* () {...@@ -226,7 +227,7 @@ const make = Effect.gen(function* () {
226227
227 // Invalidate the workspace entry cache so the @-mention file picker228 // Invalidate the workspace entry cache so the @-mention file picker
228 // reflects files created or deleted during this turn.229 // reflects files created or deleted during this turn.
229 clearWorkspaceIndexCache(input.cwd);230 yield* workspaceEntries.invalidate(input.cwd);
230231
231 const files = yield* checkpointStore232 const files = yield* checkpointStore
232 .diffCheckpoints({233 .diffCheckpoints({
...@@ -642,7 +643,7 @@ const make = Effect.gen(function* () {...@@ -642,7 +643,7 @@ const make = Effect.gen(function* () {
642643
643 // Invalidate the workspace entry cache so the @-mention file picker644 // Invalidate the workspace entry cache so the @-mention file picker
644 // reflects the reverted filesystem state.645 // reflects the reverted filesystem state.
645 clearWorkspaceIndexCache(sessionRuntime.value.cwd);646 yield* workspaceEntries.invalidate(sessionRuntime.value.cwd);
646647
647 const rolledBackTurns = Math.max(0, currentTurnCount - event.payload.turnCount);648 const rolledBackTurns = Math.max(0, currentTurnCount - event.payload.turnCount);
648 if (rolledBackTurns > 0) {649 if (rolledBackTurns > 0) {
apps/server/src/project/Layers/ProjectFaviconResolver.test.ts created+74
...@@ -0,0 +1,74 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { it, describe, expect } from "@effect/vitest";
3import { Effect, FileSystem, Layer, Path } from "effect";
4
5import { ProjectFaviconResolver } from "../Services/ProjectFaviconResolver.ts";
6import { ProjectFaviconResolverLive } from "./ProjectFaviconResolver.ts";
7
8const TestLayer = Layer.empty.pipe(
9 Layer.provideMerge(ProjectFaviconResolverLive),
10 Layer.provideMerge(NodeServices.layer),
11);
12
13const makeTempDir = Effect.gen(function* () {
14 const fileSystem = yield* FileSystem.FileSystem;
15 return yield* fileSystem.makeTempDirectoryScoped({
16 prefix: "t3code-project-favicon-",
17 });
18});
19
20const writeTextFile = Effect.fn("writeTextFile")(function* (
21 cwd: string,
22 relativePath: string,
23 contents: string,
24) {
25 const fileSystem = yield* FileSystem.FileSystem;
26 const path = yield* Path.Path;
27 const absolutePath = path.join(cwd, relativePath);
28 yield* fileSystem
29 .makeDirectory(path.dirname(absolutePath), { recursive: true })
30 .pipe(Effect.orDie);
31 yield* fileSystem.writeFileString(absolutePath, contents).pipe(Effect.orDie);
32});
33
34it.layer(TestLayer)("ProjectFaviconResolverLive", (it) => {
35 describe("resolvePath", () => {
36 it.effect("prefers well-known favicon files", () =>
37 Effect.gen(function* () {
38 const resolver = yield* ProjectFaviconResolver;
39 const cwd = yield* makeTempDir;
40 yield* writeTextFile(cwd, "favicon.svg", "<svg>favicon</svg>");
41
42 const resolved = yield* resolver.resolvePath(cwd);
43
44 expect(resolved).not.toBeNull();
45 expect(resolved).toContain("favicon.svg");
46 }),
47 );
48
49 it.effect("resolves icon hrefs from project source files", () =>
50 Effect.gen(function* () {
51 const resolver = yield* ProjectFaviconResolver;
52 const cwd = yield* makeTempDir;
53 yield* writeTextFile(cwd, "index.html", '<link rel="icon" href="/brand/logo.svg">');
54 yield* writeTextFile(cwd, "public/brand/logo.svg", "<svg>brand</svg>");
55
56 const resolved = yield* resolver.resolvePath(cwd);
57
58 expect(resolved).not.toBeNull();
59 expect(resolved).toContain("public/brand/logo.svg");
60 }),
61 );
62
63 it.effect("returns null when no icon is present", () =>
64 Effect.gen(function* () {
65 const resolver = yield* ProjectFaviconResolver;
66 const cwd = yield* makeTempDir;
67
68 const resolved = yield* resolver.resolvePath(cwd);
69
70 expect(resolved).toBeNull();
71 }),
72 );
73 });
74});
apps/server/src/project/Layers/ProjectFaviconResolver.ts created+129
...@@ -0,0 +1,129 @@
1import { Effect, FileSystem, Layer, Path } from "effect";
2
3import {
4 ProjectFaviconResolver,
5 type ProjectFaviconResolverShape,
6} from "../Services/ProjectFaviconResolver.ts";
7
8// Well-known favicon paths checked in order.
9const FAVICON_CANDIDATES = [
10 "favicon.svg",
11 "favicon.ico",
12 "favicon.png",
13 "public/favicon.svg",
14 "public/favicon.ico",
15 "public/favicon.png",
16 "app/favicon.ico",
17 "app/favicon.png",
18 "app/icon.svg",
19 "app/icon.png",
20 "app/icon.ico",
21 "src/favicon.ico",
22 "src/favicon.svg",
23 "src/app/favicon.ico",
24 "src/app/icon.svg",
25 "src/app/icon.png",
26 "assets/icon.svg",
27 "assets/icon.png",
28 "assets/logo.svg",
29 "assets/logo.png",
30] as const;
31
32// Files that may contain a <link rel="icon"> or icon metadata declaration.
33const ICON_SOURCE_FILES = [
34 "index.html",
35 "public/index.html",
36 "app/routes/__root.tsx",
37 "src/routes/__root.tsx",
38 "app/root.tsx",
39 "src/root.tsx",
40 "src/index.html",
41] as const;
42
43// Matches <link ...> tags or object-like icon metadata where rel/href can appear in any order.
44const LINK_ICON_HTML_RE =
45 /<link\b(?=[^>]*\brel=["'](?:icon|shortcut icon)["'])(?=[^>]*\bhref=["']([^"'?]+))[^>]*>/i;
46const LINK_ICON_OBJ_RE =
47 /(?=[^}]*\brel\s*:\s*["'](?:icon|shortcut icon)["'])(?=[^}]*\bhref\s*:\s*["']([^"'?]+))[^}]*/i;
48
49function extractIconHref(source: string): string | null {
50 const htmlMatch = source.match(LINK_ICON_HTML_RE);
51 if (htmlMatch?.[1]) return htmlMatch[1];
52 const objMatch = source.match(LINK_ICON_OBJ_RE);
53 if (objMatch?.[1]) return objMatch[1];
54 return null;
55}
56
57export const makeProjectFaviconResolver = Effect.gen(function* () {
58 const fileSystem = yield* FileSystem.FileSystem;
59 const path = yield* Path.Path;
60
61 const resolveIconHref = (projectCwd: string, href: string): string[] => {
62 const clean = href.replace(/^\//, "");
63 return [path.join(projectCwd, "public", clean), path.join(projectCwd, clean)];
64 };
65
66 const isPathWithinProject = (projectCwd: string, candidatePath: string): boolean => {
67 const relative = path.relative(path.resolve(projectCwd), path.resolve(candidatePath));
68 return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
69 };
70
71 const findExistingFile = Effect.fn("ProjectFaviconResolver.findExistingFile")(function* (
72 projectCwd: string,
73 candidates: ReadonlyArray<string>,
74 ): Effect.fn.Return<string | null> {
75 for (const candidate of candidates) {
76 if (!isPathWithinProject(projectCwd, candidate)) {
77 continue;
78 }
79 const stats = yield* fileSystem
80 .stat(candidate)
81 .pipe(Effect.catch(() => Effect.succeed(null)));
82 if (stats?.type === "File") {
83 return candidate;
84 }
85 }
86 return null;
87 });
88
89 const resolvePath: ProjectFaviconResolverShape["resolvePath"] = Effect.fn(
90 "ProjectFaviconResolver.resolvePath",
91 )(function* (cwd: string): Effect.fn.Return<string | null> {
92 for (const candidate of FAVICON_CANDIDATES) {
93 const resolved = path.join(cwd, candidate);
94 const existing = yield* findExistingFile(cwd, [resolved]);
95 if (existing) {
96 return existing;
97 }
98 }
99
100 for (const sourceFile of ICON_SOURCE_FILES) {
101 const sourcePath = path.join(cwd, sourceFile);
102 const source = yield* fileSystem
103 .readFileString(sourcePath)
104 .pipe(Effect.catch(() => Effect.succeed(null)));
105 if (!source) {
106 continue;
107 }
108 const href = extractIconHref(source);
109 if (!href) {
110 continue;
111 }
112 const existing = yield* findExistingFile(cwd, resolveIconHref(cwd, href));
113 if (existing) {
114 return existing;
115 }
116 }
117
118 return null;
119 });
120
121 return {
122 resolvePath,
123 } satisfies ProjectFaviconResolverShape;
124});
125
126export const ProjectFaviconResolverLive = Layer.effect(
127 ProjectFaviconResolver,
128 makeProjectFaviconResolver,
129);
apps/server/src/project/Services/ProjectFaviconResolver.ts created+30
...@@ -0,0 +1,30 @@
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 { ServiceMap } from "effect";
10import type { Effect } from "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 ServiceMap.Service<
28 ProjectFaviconResolver,
29 ProjectFaviconResolverShape
30>()("t3/project/Services/ProjectFaviconResolver") {}
apps/server/src/projectFaviconRoute.test.ts+23-5
...@@ -3,7 +3,11 @@ import http from "node:http";...@@ -3,7 +3,11 @@ import http from "node:http";
3import os from "node:os";3import os from "node:os";
4import path from "node:path";4import path from "node:path";
55
6import * as NodeServices from "@effect/platform-node/NodeServices";
7import { Effect } from "effect";
6import { afterEach, describe, expect, it } from "vitest";8import { afterEach, describe, expect, it } from "vitest";
9
10import { ProjectFaviconResolverLive } from "./project/Layers/ProjectFaviconResolver.ts";
7import { tryHandleProjectFaviconRequest } from "./projectFaviconRoute";11import { tryHandleProjectFaviconRequest } from "./projectFaviconRoute";
812
9interface HttpResponse {13interface HttpResponse {
...@@ -23,11 +27,25 @@ function makeTempDir(prefix: string): string {...@@ -23,11 +27,25 @@ function makeTempDir(prefix: string): string {
23async function withRouteServer(run: (baseUrl: string) => Promise<void>): Promise<void> {27async function withRouteServer(run: (baseUrl: string) => Promise<void>): Promise<void> {
24 const server = http.createServer((req, res) => {28 const server = http.createServer((req, res) => {
25 const url = new URL(req.url ?? "/", "http://127.0.0.1");29 const url = new URL(req.url ?? "/", "http://127.0.0.1");
26 if (tryHandleProjectFaviconRequest(url, res)) {30 void Effect.runPromise(
27 return;31 tryHandleProjectFaviconRequest(url, res).pipe(
28 }32 Effect.provide(ProjectFaviconResolverLive),
29 res.writeHead(404, { "Content-Type": "text/plain" });33 Effect.provide(NodeServices.layer),
30 res.end("Not Found");34 Effect.flatMap((handled) =>
35 handled
36 ? Effect.void
37 : Effect.sync(() => {
38 res.writeHead(404, { "Content-Type": "text/plain" });
39 res.end("Not Found");
40 }),
41 ),
42 ),
43 ).catch(() => {
44 if (!res.headersSent) {
45 res.writeHead(500, { "Content-Type": "text/plain" });
46 res.end("Internal Server Error");
47 }
48 });
31 });49 });
3250
33 await new Promise<void>((resolve, reject) => {51 await new Promise<void>((resolve, reject) => {
apps/server/src/projectFaviconRoute.ts+56-151
...@@ -1,6 +1,8 @@...@@ -1,6 +1,8 @@
1import fs from "node:fs";
2import http from "node:http";1import http from "node:http";
3import path from "node:path";2import path from "node:path";
3import { Effect, FileSystem } from "effect";
4
5import { ProjectFaviconResolver } from "./project/Services/ProjectFaviconResolver.ts";
46
5const FAVICON_MIME_TYPES: Record<string, string> = {7const FAVICON_MIME_TYPES: Record<string, string> = {
6 ".png": "image/png",8 ".png": "image/png",
...@@ -11,161 +13,64 @@ const FAVICON_MIME_TYPES: Record<string, string> = {...@@ -11,161 +13,64 @@ const FAVICON_MIME_TYPES: Record<string, string> = {
1113
12const FALLBACK_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>`;14const FALLBACK_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>`;
1315
14// Well-known favicon paths checked in order.16export const tryHandleProjectFaviconRequest = Effect.fn("tryHandleProjectFaviconRequest")(
15const FAVICON_CANDIDATES = [17 function* (
16 "favicon.svg",18 url: URL,
17 "favicon.ico",19 res: http.ServerResponse,
18 "favicon.png",20 ): Effect.fn.Return<boolean, never, ProjectFaviconResolver | FileSystem.FileSystem> {
19 "public/favicon.svg",21 const respond = (
20 "public/favicon.ico",22 statusCode: number,
21 "public/favicon.png",23 headers: Record<string, string>,
22 "app/favicon.ico",24 body: string | Uint8Array,
23 "app/favicon.png",25 ) => {
24 "app/icon.svg",26 res.writeHead(statusCode, headers);
25 "app/icon.png",27 res.end(body);
26 "app/icon.ico",28 };
27 "src/favicon.ico",29
28 "src/favicon.svg",30 if (url.pathname !== "/api/project-favicon") {
29 "src/app/favicon.ico",31 return false;
30 "src/app/icon.svg",
31 "src/app/icon.png",
32 "assets/icon.svg",
33 "assets/icon.png",
34 "assets/logo.svg",
35 "assets/logo.png",
36];
37
38// Files that may contain a <link rel="icon"> or icon metadata declaration.
39const ICON_SOURCE_FILES = [
40 "index.html",
41 "public/index.html",
42 "app/routes/__root.tsx",
43 "src/routes/__root.tsx",
44 "app/root.tsx",
45 "src/root.tsx",
46 "src/index.html",
47];
48
49// Matches <link ...> tags or object-like icon metadata where rel/href can appear in any order.
50const LINK_ICON_HTML_RE =
51 /<link\b(?=[^>]*\brel=["'](?:icon|shortcut icon)["'])(?=[^>]*\bhref=["']([^"'?]+))[^>]*>/i;
52const LINK_ICON_OBJ_RE =
53 /(?=[^}]*\brel\s*:\s*["'](?:icon|shortcut icon)["'])(?=[^}]*\bhref\s*:\s*["']([^"'?]+))[^}]*/i;
54
55function extractIconHref(source: string): string | null {
56 const htmlMatch = source.match(LINK_ICON_HTML_RE);
57 if (htmlMatch?.[1]) return htmlMatch[1];
58 const objMatch = source.match(LINK_ICON_OBJ_RE);
59 if (objMatch?.[1]) return objMatch[1];
60 return null;
61}
62
63function resolveIconHref(projectCwd: string, href: string): string[] {
64 const clean = href.replace(/^\//, "");
65 return [path.join(projectCwd, "public", clean), path.join(projectCwd, clean)];
66}
67
68function isPathWithinProject(projectCwd: string, candidatePath: string): boolean {
69 const relative = path.relative(path.resolve(projectCwd), path.resolve(candidatePath));
70 return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
71}
72
73function serveFaviconFile(filePath: string, res: http.ServerResponse): void {
74 const ext = path.extname(filePath).toLowerCase();
75 const contentType = FAVICON_MIME_TYPES[ext] ?? "application/octet-stream";
76 fs.readFile(filePath, (readErr, data) => {
77 if (readErr) {
78 res.writeHead(500, { "Content-Type": "text/plain" });
79 res.end("Read error");
80 return;
81 }32 }
82 res.writeHead(200, {
83 "Content-Type": contentType,
84 "Cache-Control": "public, max-age=3600",
85 });
86 res.end(data);
87 });
88}
8933
90function serveFallbackFavicon(res: http.ServerResponse): void {34 const projectCwd = url.searchParams.get("cwd");
91 res.writeHead(200, {35 if (!projectCwd) {
92 "Content-Type": "image/svg+xml",36 respond(400, { "Content-Type": "text/plain" }, "Missing cwd parameter");
93 "Cache-Control": "public, max-age=3600",37 return true;
94 });
95 res.end(FALLBACK_FAVICON_SVG);
96}
97
98export function tryHandleProjectFaviconRequest(url: URL, res: http.ServerResponse): boolean {
99 if (url.pathname !== "/api/project-favicon") {
100 return false;
101 }
102
103 const projectCwd = url.searchParams.get("cwd");
104 if (!projectCwd) {
105 res.writeHead(400, { "Content-Type": "text/plain" });
106 res.end("Missing cwd parameter");
107 return true;
108 }
109
110 const tryResolvedPaths = (paths: string[], index: number, onExhausted: () => void): void => {
111 if (index >= paths.length) {
112 onExhausted();
113 return;
114 }38 }
115 const candidate = paths[index]!;
116 if (!isPathWithinProject(projectCwd, candidate)) {
117 tryResolvedPaths(paths, index + 1, onExhausted);
118 return;
119 }
120 fs.stat(candidate, (err, stats) => {
121 if (err || !stats?.isFile()) {
122 tryResolvedPaths(paths, index + 1, onExhausted);
123 return;
124 }
125 serveFaviconFile(candidate, res);
126 });
127 };
12839
129 const trySourceFiles = (index: number): void => {40 const fileSystem = yield* FileSystem.FileSystem;
130 if (index >= ICON_SOURCE_FILES.length) {41 const faviconResolver = yield* ProjectFaviconResolver;
131 serveFallbackFavicon(res);42 const resolvedPath = yield* faviconResolver.resolvePath(projectCwd);
132 return;43
44 if (!resolvedPath) {
45 respond(
46 200,
47 {
48 "Content-Type": "image/svg+xml",
49 "Cache-Control": "public, max-age=3600",
50 },
51 FALLBACK_FAVICON_SVG,
52 );
53 return true;
133 }54 }
134 const sourceFile = path.join(projectCwd, ICON_SOURCE_FILES[index]!);
135 fs.readFile(sourceFile, "utf8", (err, content) => {
136 if (err) {
137 trySourceFiles(index + 1);
138 return;
139 }
140 const href = extractIconHref(content);
141 if (!href) {
142 trySourceFiles(index + 1);
143 return;
144 }
145 const candidates = resolveIconHref(projectCwd, href);
146 tryResolvedPaths(candidates, 0, () => trySourceFiles(index + 1));
147 });
148 };
14955
150 const tryCandidates = (index: number): void => {56 const data = yield* fileSystem
151 if (index >= FAVICON_CANDIDATES.length) {57 .readFile(resolvedPath)
152 trySourceFiles(0);58 .pipe(Effect.catch(() => Effect.succeed(null)));
153 return;59 if (!data) {
154 }60 respond(500, { "Content-Type": "text/plain" }, "Read error");
155 const candidate = path.join(projectCwd, FAVICON_CANDIDATES[index]!);61 return true;
156 if (!isPathWithinProject(projectCwd, candidate)) {
157 tryCandidates(index + 1);
158 return;
159 }62 }
160 fs.stat(candidate, (err, stats) => {
161 if (err || !stats?.isFile()) {
162 tryCandidates(index + 1);
163 return;
164 }
165 serveFaviconFile(candidate, res);
166 });
167 };
16863
169 tryCandidates(0);64 const ext = path.extname(resolvedPath).toLowerCase();
170 return true;65 const contentType = FAVICON_MIME_TYPES[ext] ?? "application/octet-stream";
171}66 respond(
67 200,
68 {
69 "Content-Type": contentType,
70 "Cache-Control": "public, max-age=3600",
71 },
72 data,
73 );
74 return true;
75 },
76);
apps/server/src/serverLayers.ts+20-5
...@@ -34,6 +34,10 @@ import { GitHubCliLive } from "./git/Layers/GitHubCli";...@@ -34,6 +34,10 @@ import { GitHubCliLive } from "./git/Layers/GitHubCli";
34import { RoutingTextGenerationLive } from "./git/Layers/RoutingTextGeneration";34import { RoutingTextGenerationLive } from "./git/Layers/RoutingTextGeneration";
35import { PtyAdapter } from "./terminal/Services/PTY";35import { PtyAdapter } from "./terminal/Services/PTY";
36import { AnalyticsService } from "./telemetry/Services/AnalyticsService";36import { AnalyticsService } from "./telemetry/Services/AnalyticsService";
37import { ProjectFaviconResolverLive } from "./project/Layers/ProjectFaviconResolver.ts";
38import { WorkspaceEntriesLive } from "./workspace/Layers/WorkspaceEntries.ts";
39import { WorkspaceFileSystemLive } from "./workspace/Layers/WorkspaceFileSystem.ts";
40import { WorkspacePathsLive } from "./workspace/Layers/WorkspacePaths.ts";
3741
38type RuntimePtyAdapterLoader = {42type RuntimePtyAdapterLoader = {
39 layer: Layer.Layer<PtyAdapter, never, FileSystem.FileSystem | Path.Path>;43 layer: Layer.Layer<PtyAdapter, never, FileSystem.FileSystem | Path.Path>;
...@@ -91,7 +95,8 @@ export function makeServerProviderLayer(): Layer.Layer<...@@ -91,7 +95,8 @@ export function makeServerProviderLayer(): Layer.Layer<
9195
92export function makeServerRuntimeServicesLayer() {96export function makeServerRuntimeServicesLayer() {
93 const textGenerationLayer = RoutingTextGenerationLive;97 const textGenerationLayer = RoutingTextGenerationLive;
94 const checkpointStoreLayer = CheckpointStoreLive.pipe(Layer.provide(GitCoreLive));98 const gitCoreLayer = GitCoreLive;
99 const checkpointStoreLayer = CheckpointStoreLive;
95100
96 const orchestrationLayer = OrchestrationEngineLive.pipe(101 const orchestrationLayer = OrchestrationEngineLive.pipe(
97 Layer.provide(OrchestrationProjectionPipelineLive),102 Layer.provide(OrchestrationProjectionPipelineLive),
...@@ -116,11 +121,11 @@ export function makeServerRuntimeServicesLayer() {...@@ -116,11 +121,11 @@ export function makeServerRuntimeServicesLayer() {
116 );121 );
117 const providerCommandReactorLayer = ProviderCommandReactorLive.pipe(122 const providerCommandReactorLayer = ProviderCommandReactorLive.pipe(
118 Layer.provideMerge(runtimeServicesLayer),123 Layer.provideMerge(runtimeServicesLayer),
119 Layer.provideMerge(GitCoreLive),
120 Layer.provideMerge(textGenerationLayer),124 Layer.provideMerge(textGenerationLayer),
121 );125 );
122 const checkpointReactorLayer = CheckpointReactorLive.pipe(126 const checkpointReactorLayer = CheckpointReactorLive.pipe(
123 Layer.provideMerge(runtimeServicesLayer),127 Layer.provideMerge(runtimeServicesLayer),
128 Layer.provideMerge(WorkspaceEntriesLive),
124 );129 );
125 const orchestrationReactorLayer = OrchestrationReactorLive.pipe(130 const orchestrationReactorLayer = OrchestrationReactorLive.pipe(
126 Layer.provideMerge(runtimeIngestionLayer),131 Layer.provideMerge(runtimeIngestionLayer),
...@@ -131,16 +136,26 @@ export function makeServerRuntimeServicesLayer() {...@@ -131,16 +136,26 @@ export function makeServerRuntimeServicesLayer() {
131 const terminalLayer = TerminalManagerLive.pipe(Layer.provide(makeRuntimePtyAdapterLayer()));136 const terminalLayer = TerminalManagerLive.pipe(Layer.provide(makeRuntimePtyAdapterLayer()));
132137
133 const gitManagerLayer = GitManagerLive.pipe(138 const gitManagerLayer = GitManagerLive.pipe(
134 Layer.provideMerge(GitCoreLive),
135 Layer.provideMerge(GitHubCliLive),139 Layer.provideMerge(GitHubCliLive),
136 Layer.provideMerge(textGenerationLayer),140 Layer.provideMerge(textGenerationLayer),
137 );141 );
138142
143 const workspacePathsLayer = WorkspacePathsLive;
144 const workspaceEntriesLayer = WorkspaceEntriesLive;
145 const workspaceFileSystemLayer = WorkspaceFileSystemLive.pipe(
146 Layer.provide(workspacePathsLayer),
147 Layer.provide(workspaceEntriesLayer),
148 );
149 const projectFaviconResolverLayer = ProjectFaviconResolverLive;
150
139 return Layer.mergeAll(151 return Layer.mergeAll(
140 orchestrationReactorLayer,152 orchestrationReactorLayer,
141 GitCoreLive,153 workspacePathsLayer,
154 workspaceEntriesLayer,
155 workspaceFileSystemLayer,
156 projectFaviconResolverLayer,
142 gitManagerLayer,157 gitManagerLayer,
143 terminalLayer,158 terminalLayer,
144 KeybindingsLive,159 KeybindingsLive,
145 ).pipe(Layer.provideMerge(NodeServices.layer));160 ).pipe(Layer.provideMerge(gitCoreLayer), Layer.provideMerge(NodeServices.layer));
146}161}
apps/server/src/workspace/Layers/WorkspaceEntries.test.ts created+264
...@@ -0,0 +1,264 @@
1import fsPromises from "node:fs/promises";
2
3import * as NodeServices from "@effect/platform-node/NodeServices";
4import { it, afterEach, describe, expect, vi } from "@effect/vitest";
5import { Effect, FileSystem, Layer, Path, PlatformError } from "effect";
6
7import { ServerConfig } from "../../config.ts";
8import { GitCoreLive } from "../../git/Layers/GitCore.ts";
9import { GitCore } from "../../git/Services/GitCore.ts";
10import { WorkspaceEntries } from "../Services/WorkspaceEntries.ts";
11import { WorkspaceEntriesLive } from "./WorkspaceEntries.ts";
12
13const TestLayer = Layer.empty.pipe(
14 Layer.provideMerge(WorkspaceEntriesLive),
15 Layer.provideMerge(GitCoreLive),
16 Layer.provide(
17 ServerConfig.layerTest(process.cwd(), {
18 prefix: "t3-workspace-entries-test-",
19 }),
20 ),
21 Layer.provideMerge(NodeServices.layer),
22);
23
24const makeTempDir = Effect.fn(function* (opts?: { prefix?: string; git?: boolean }) {
25 const fileSystem = yield* FileSystem.FileSystem;
26 const gitCore = yield* GitCore;
27 const dir = yield* fileSystem.makeTempDirectoryScoped({
28 prefix: opts?.prefix ?? "t3code-workspace-entries-",
29 });
30 if (opts?.git) {
31 yield* gitCore.initRepo({ cwd: dir });
32 }
33 return dir;
34});
35
36function writeTextFile(
37 cwd: string,
38 relativePath: string,
39 contents = "",
40): Effect.Effect<void, PlatformError.PlatformError, FileSystem.FileSystem | Path.Path> {
41 return Effect.gen(function* () {
42 const fileSystem = yield* FileSystem.FileSystem;
43 const path = yield* Path.Path;
44 const absolutePath = path.join(cwd, relativePath);
45 yield* fileSystem.makeDirectory(path.dirname(absolutePath), { recursive: true });
46 yield* fileSystem.writeFileString(absolutePath, contents);
47 });
48}
49
50const git = (cwd: string, args: ReadonlyArray<string>, env?: NodeJS.ProcessEnv) =>
51 Effect.gen(function* () {
52 const gitCore = yield* GitCore;
53 const result = yield* gitCore.execute({
54 operation: "WorkspaceEntries.test.git",
55 cwd,
56 args,
57 ...(env ? { env } : {}),
58 timeoutMs: 10_000,
59 });
60 return result.stdout.trim();
61 });
62
63const searchWorkspaceEntries = (input: { cwd: string; query: string; limit: number }) =>
64 Effect.gen(function* () {
65 const workspaceEntries = yield* WorkspaceEntries;
66 return yield* workspaceEntries.search(input);
67 });
68
69it.layer(TestLayer)("WorkspaceEntriesLive", (it) => {
70 afterEach(() => {
71 vi.restoreAllMocks();
72 });
73
74 describe("search", () => {
75 it.effect("returns files and directories relative to cwd", () =>
76 Effect.gen(function* () {
77 const cwd = yield* makeTempDir();
78 yield* writeTextFile(cwd, "src/components/Composer.tsx");
79 yield* writeTextFile(cwd, "src/index.ts");
80 yield* writeTextFile(cwd, "README.md");
81 yield* writeTextFile(cwd, ".git/HEAD");
82 yield* writeTextFile(cwd, "node_modules/pkg/index.js");
83
84 const result = yield* searchWorkspaceEntries({ cwd, query: "", limit: 100 });
85 const paths = result.entries.map((entry) => entry.path);
86
87 expect(paths).toContain("src");
88 expect(paths).toContain("src/components");
89 expect(paths).toContain("src/components/Composer.tsx");
90 expect(paths).toContain("README.md");
91 expect(paths.some((entryPath) => entryPath.startsWith(".git"))).toBe(false);
92 expect(paths.some((entryPath) => entryPath.startsWith("node_modules"))).toBe(false);
93 expect(result.truncated).toBe(false);
94 }),
95 );
96
97 it.effect("filters and ranks entries by query", () =>
98 Effect.gen(function* () {
99 const cwd = yield* makeTempDir({ prefix: "t3code-workspace-query-" });
100 yield* writeTextFile(cwd, "src/components/Composer.tsx");
101 yield* writeTextFile(cwd, "src/components/composePrompt.ts");
102 yield* writeTextFile(cwd, "docs/composition.md");
103
104 const result = yield* searchWorkspaceEntries({ cwd, query: "compo", limit: 5 });
105
106 expect(result.entries.length).toBeGreaterThan(0);
107 expect(result.entries.some((entry) => entry.path === "src/components")).toBe(true);
108 expect(result.entries.every((entry) => entry.path.toLowerCase().includes("compo"))).toBe(
109 true,
110 );
111 }),
112 );
113
114 it.effect("supports fuzzy subsequence queries for composer path search", () =>
115 Effect.gen(function* () {
116 const cwd = yield* makeTempDir({ prefix: "t3code-workspace-fuzzy-query-" });
117 yield* writeTextFile(cwd, "src/components/Composer.tsx");
118 yield* writeTextFile(cwd, "src/components/composePrompt.ts");
119 yield* writeTextFile(cwd, "docs/composition.md");
120
121 const result = yield* searchWorkspaceEntries({ cwd, query: "cmp", limit: 10 });
122 const paths = result.entries.map((entry) => entry.path);
123
124 expect(result.entries.length).toBeGreaterThan(0);
125 expect(paths).toContain("src/components");
126 expect(paths).toContain("src/components/Composer.tsx");
127 }),
128 );
129
130 it.effect("tracks truncation without sorting every fuzzy match", () =>
131 Effect.gen(function* () {
132 const cwd = yield* makeTempDir({ prefix: "t3code-workspace-fuzzy-limit-" });
133 yield* writeTextFile(cwd, "src/components/Composer.tsx");
134 yield* writeTextFile(cwd, "src/components/composePrompt.ts");
135 yield* writeTextFile(cwd, "docs/composition.md");
136
137 const result = yield* searchWorkspaceEntries({ cwd, query: "cmp", limit: 1 });
138
139 expect(result.entries).toHaveLength(1);
140 expect(result.truncated).toBe(true);
141 }),
142 );
143
144 it.effect("excludes gitignored paths for git repositories", () =>
145 Effect.gen(function* () {
146 const cwd = yield* makeTempDir({ prefix: "t3code-workspace-gitignore-", git: true });
147 yield* writeTextFile(cwd, ".gitignore", ".convex/\nconvex/\nignored.txt\n");
148 yield* writeTextFile(cwd, "src/keep.ts", "export {};");
149 yield* writeTextFile(cwd, "ignored.txt", "ignore me");
150 yield* writeTextFile(cwd, ".convex/local-storage/data.json", "{}");
151 yield* writeTextFile(cwd, "convex/UOoS-l/convex_local_storage/modules/data.json", "{}");
152
153 const result = yield* searchWorkspaceEntries({ cwd, query: "", limit: 100 });
154 const paths = result.entries.map((entry) => entry.path);
155
156 expect(paths).toContain("src");
157 expect(paths).toContain("src/keep.ts");
158 expect(paths).not.toContain("ignored.txt");
159 expect(paths.some((entryPath) => entryPath.startsWith(".convex/"))).toBe(false);
160 expect(paths.some((entryPath) => entryPath.startsWith("convex/"))).toBe(false);
161 }),
162 );
163
164 it.effect("excludes tracked paths that match ignore rules", () =>
165 Effect.gen(function* () {
166 const cwd = yield* makeTempDir({
167 prefix: "t3code-workspace-tracked-gitignore-",
168 git: true,
169 });
170 yield* writeTextFile(cwd, ".convex/local-storage/data.json", "{}");
171 yield* writeTextFile(cwd, "src/keep.ts", "export {};");
172 yield* git(cwd, ["add", ".convex/local-storage/data.json", "src/keep.ts"]);
173 yield* writeTextFile(cwd, ".gitignore", ".convex/\n");
174
175 const result = yield* searchWorkspaceEntries({ cwd, query: "", limit: 100 });
176 const paths = result.entries.map((entry) => entry.path);
177
178 expect(paths).toContain("src");
179 expect(paths).toContain("src/keep.ts");
180 expect(paths.some((entryPath) => entryPath.startsWith(".convex/"))).toBe(false);
181 }),
182 );
183
184 it.effect("excludes .convex in non-git workspaces", () =>
185 Effect.gen(function* () {
186 const cwd = yield* makeTempDir({ prefix: "t3code-workspace-non-git-convex-" });
187 yield* writeTextFile(cwd, ".convex/local-storage/data.json", "{}");
188 yield* writeTextFile(cwd, "src/keep.ts", "export {};");
189
190 const result = yield* searchWorkspaceEntries({ cwd, query: "", limit: 100 });
191 const paths = result.entries.map((entry) => entry.path);
192
193 expect(paths).toContain("src");
194 expect(paths).toContain("src/keep.ts");
195 expect(paths.some((entryPath) => entryPath.startsWith(".convex/"))).toBe(false);
196 }),
197 );
198
199 it.effect("deduplicates concurrent index builds for the same cwd", () =>
200 Effect.gen(function* () {
201 const cwd = yield* makeTempDir({ prefix: "t3code-workspace-concurrent-build-" });
202 yield* writeTextFile(cwd, "src/components/Composer.tsx");
203
204 let rootReadCount = 0;
205 const originalReaddir = fsPromises.readdir.bind(fsPromises);
206 vi.spyOn(fsPromises, "readdir").mockImplementation((async (
207 ...args: Parameters<typeof fsPromises.readdir>
208 ) => {
209 if (args[0] === cwd) {
210 rootReadCount += 1;
211 await new Promise((resolve) => setTimeout(resolve, 20));
212 }
213 return originalReaddir(...args);
214 }) as typeof fsPromises.readdir);
215
216 yield* Effect.all(
217 [
218 searchWorkspaceEntries({ cwd, query: "", limit: 100 }),
219 searchWorkspaceEntries({ cwd, query: "comp", limit: 100 }),
220 searchWorkspaceEntries({ cwd, query: "src", limit: 100 }),
221 ],
222 { concurrency: "unbounded" },
223 );
224
225 expect(rootReadCount).toBe(1);
226 }),
227 );
228
229 it.effect("limits concurrent directory reads while walking the filesystem", () =>
230 Effect.gen(function* () {
231 const cwd = yield* makeTempDir({ prefix: "t3code-workspace-read-concurrency-" });
232 yield* Effect.forEach(
233 Array.from({ length: 80 }, (_, index) => index),
234 (index) => writeTextFile(cwd, `group-${index}/entry-${index}.ts`, "export {};"),
235 { discard: true },
236 );
237
238 let activeReads = 0;
239 let peakReads = 0;
240 const originalReaddir = fsPromises.readdir.bind(fsPromises);
241 vi.spyOn(fsPromises, "readdir").mockImplementation((async (
242 ...args: Parameters<typeof fsPromises.readdir>
243 ) => {
244 const target = args[0];
245 if (typeof target === "string" && target.startsWith(cwd)) {
246 activeReads += 1;
247 peakReads = Math.max(peakReads, activeReads);
248 await new Promise((resolve) => setTimeout(resolve, 4));
249 try {
250 return await originalReaddir(...args);
251 } finally {
252 activeReads -= 1;
253 }
254 }
255 return originalReaddir(...args);
256 }) as typeof fsPromises.readdir);
257
258 yield* searchWorkspaceEntries({ cwd, query: "", limit: 200 });
259
260 expect(peakReads).toBeLessThanOrEqual(32);
261 }),
262 );
263 });
264});
apps/server/src/workspace/Layers/WorkspaceEntries.ts created+478
...@@ -0,0 +1,478 @@
1import fsPromises from "node:fs/promises";
2import type { Dirent } from "node:fs";
3
4import { Cache, Duration, Effect, Exit, Layer, Option, Path } from "effect";
5
6import { type ProjectEntry } from "@t3tools/contracts";
7
8import { GitCore } from "../../git/Services/GitCore.ts";
9import {
10 WorkspaceEntries,
11 WorkspaceEntriesError,
12 type WorkspaceEntriesShape,
13} from "../Services/WorkspaceEntries.ts";
14
15const WORKSPACE_CACHE_TTL_MS = 15_000;
16const WORKSPACE_CACHE_MAX_KEYS = 4;
17const WORKSPACE_INDEX_MAX_ENTRIES = 25_000;
18const WORKSPACE_SCAN_READDIR_CONCURRENCY = 32;
19const IGNORED_DIRECTORY_NAMES = new Set([
20 ".git",
21 ".convex",
22 "node_modules",
23 ".next",
24 ".turbo",
25 "dist",
26 "build",
27 "out",
28 ".cache",
29]);
30
31interface WorkspaceIndex {
32 scannedAt: number;
33 entries: SearchableWorkspaceEntry[];
34 truncated: boolean;
35}
36
37interface SearchableWorkspaceEntry extends ProjectEntry {
38 normalizedPath: string;
39 normalizedName: string;
40}
41
42interface RankedWorkspaceEntry {
43 entry: SearchableWorkspaceEntry;
44 score: number;
45}
46
47function toPosixPath(input: string): string {
48 return input.replaceAll("\\", "/");
49}
50
51function parentPathOf(input: string): string | undefined {
52 const separatorIndex = input.lastIndexOf("/");
53 if (separatorIndex === -1) {
54 return undefined;
55 }
56 return input.slice(0, separatorIndex);
57}
58
59function basenameOf(input: string): string {
60 const separatorIndex = input.lastIndexOf("/");
61 if (separatorIndex === -1) {
62 return input;
63 }
64 return input.slice(separatorIndex + 1);
65}
66
67function toSearchableWorkspaceEntry(entry: ProjectEntry): SearchableWorkspaceEntry {
68 const normalizedPath = entry.path.toLowerCase();
69 return {
70 ...entry,
71 normalizedPath,
72 normalizedName: basenameOf(normalizedPath),
73 };
74}
75
76function normalizeQuery(input: string): string {
77 return input
78 .trim()
79 .replace(/^[@./]+/, "")
80 .toLowerCase();
81}
82
83function scoreSubsequenceMatch(value: string, query: string): number | null {
84 if (!query) return 0;
85
86 let queryIndex = 0;
87 let firstMatchIndex = -1;
88 let previousMatchIndex = -1;
89 let gapPenalty = 0;
90
91 for (let valueIndex = 0; valueIndex < value.length; valueIndex += 1) {
92 if (value[valueIndex] !== query[queryIndex]) {
93 continue;
94 }
95
96 if (firstMatchIndex === -1) {
97 firstMatchIndex = valueIndex;
98 }
99 if (previousMatchIndex !== -1) {
100 gapPenalty += valueIndex - previousMatchIndex - 1;
101 }
102
103 previousMatchIndex = valueIndex;
104 queryIndex += 1;
105 if (queryIndex === query.length) {
106 const spanPenalty = valueIndex - firstMatchIndex + 1 - query.length;
107 const lengthPenalty = Math.min(64, value.length - query.length);
108 return firstMatchIndex * 2 + gapPenalty * 3 + spanPenalty + lengthPenalty;
109 }
110 }
111
112 return null;
113}
114
115function scoreEntry(entry: SearchableWorkspaceEntry, query: string): number | null {
116 if (!query) {
117 return entry.kind === "directory" ? 0 : 1;
118 }
119
120 const { normalizedPath, normalizedName } = entry;
121
122 if (normalizedName === query) return 0;
123 if (normalizedPath === query) return 1;
124 if (normalizedName.startsWith(query)) return 2;
125 if (normalizedPath.startsWith(query)) return 3;
126 if (normalizedPath.includes(`/${query}`)) return 4;
127 if (normalizedName.includes(query)) return 5;
128 if (normalizedPath.includes(query)) return 6;
129
130 const nameFuzzyScore = scoreSubsequenceMatch(normalizedName, query);
131 if (nameFuzzyScore !== null) {
132 return 100 + nameFuzzyScore;
133 }
134
135 const pathFuzzyScore = scoreSubsequenceMatch(normalizedPath, query);
136 if (pathFuzzyScore !== null) {
137 return 200 + pathFuzzyScore;
138 }
139
140 return null;
141}
142
143function compareRankedWorkspaceEntries(
144 left: RankedWorkspaceEntry,
145 right: RankedWorkspaceEntry,
146): number {
147 const scoreDelta = left.score - right.score;
148 if (scoreDelta !== 0) return scoreDelta;
149 return left.entry.path.localeCompare(right.entry.path);
150}
151
152function findInsertionIndex(
153 rankedEntries: RankedWorkspaceEntry[],
154 candidate: RankedWorkspaceEntry,
155): number {
156 let low = 0;
157 let high = rankedEntries.length;
158
159 while (low < high) {
160 const middle = low + Math.floor((high - low) / 2);
161 const current = rankedEntries[middle];
162 if (!current) {
163 break;
164 }
165
166 if (compareRankedWorkspaceEntries(candidate, current) < 0) {
167 high = middle;
168 } else {
169 low = middle + 1;
170 }
171 }
172
173 return low;
174}
175
176function insertRankedEntry(
177 rankedEntries: RankedWorkspaceEntry[],
178 candidate: RankedWorkspaceEntry,
179 limit: number,
180): void {
181 if (limit <= 0) {
182 return;
183 }
184
185 const insertionIndex = findInsertionIndex(rankedEntries, candidate);
186 if (rankedEntries.length < limit) {
187 rankedEntries.splice(insertionIndex, 0, candidate);
188 return;
189 }
190
191 if (insertionIndex >= limit) {
192 return;
193 }
194
195 rankedEntries.splice(insertionIndex, 0, candidate);
196 rankedEntries.pop();
197}
198
199function isPathInIgnoredDirectory(relativePath: string): boolean {
200 const firstSegment = relativePath.split("/")[0];
201 if (!firstSegment) return false;
202 return IGNORED_DIRECTORY_NAMES.has(firstSegment);
203}
204
205function directoryAncestorsOf(relativePath: string): string[] {
206 const segments = relativePath.split("/").filter((segment) => segment.length > 0);
207 if (segments.length <= 1) return [];
208
209 const directories: string[] = [];
210 for (let index = 1; index < segments.length; index += 1) {
211 directories.push(segments.slice(0, index).join("/"));
212 }
213 return directories;
214}
215
216const processErrorDetail = (cause: unknown): string =>
217 cause instanceof Error ? cause.message : String(cause);
218
219export const makeWorkspaceEntries = Effect.gen(function* () {
220 const path = yield* Path.Path;
221 const gitOption = yield* Effect.serviceOption(GitCore);
222
223 const isInsideGitWorkTree = (cwd: string): Effect.Effect<boolean> =>
224 Option.match(gitOption, {
225 onSome: (git) => git.isInsideWorkTree(cwd).pipe(Effect.catch(() => Effect.succeed(false))),
226 onNone: () => Effect.succeed(false),
227 });
228
229 const filterGitIgnoredPaths = (
230 cwd: string,
231 relativePaths: string[],
232 ): Effect.Effect<string[], never> =>
233 Option.match(gitOption, {
234 onSome: (git) =>
235 git.filterIgnoredPaths(cwd, relativePaths).pipe(
236 Effect.map((paths) => [...paths]),
237 Effect.catch(() => Effect.succeed(relativePaths)),
238 ),
239 onNone: () => Effect.succeed(relativePaths),
240 });
241
242 const buildWorkspaceIndexFromGit = Effect.fn("WorkspaceEntries.buildWorkspaceIndexFromGit")(
243 function* (cwd: string) {
244 if (Option.isNone(gitOption)) {
245 return null;
246 }
247 if (!(yield* isInsideGitWorkTree(cwd))) {
248 return null;
249 }
250
251 const listedFiles = yield* gitOption.value
252 .listWorkspaceFiles(cwd)
253 .pipe(Effect.catch(() => Effect.succeed(null)));
254
255 if (!listedFiles) {
256 return null;
257 }
258
259 const listedPaths = [...listedFiles.paths]
260 .map((entry) => toPosixPath(entry))
261 .filter((entry) => entry.length > 0 && !isPathInIgnoredDirectory(entry));
262 const filePaths = yield* filterGitIgnoredPaths(cwd, listedPaths);
263
264 const directorySet = new Set<string>();
265 for (const filePath of filePaths) {
266 for (const directoryPath of directoryAncestorsOf(filePath)) {
267 if (!isPathInIgnoredDirectory(directoryPath)) {
268 directorySet.add(directoryPath);
269 }
270 }
271 }
272
273 const directoryEntries = [...directorySet]
274 .toSorted((left, right) => left.localeCompare(right))
275 .map(
276 (directoryPath): ProjectEntry => ({
277 path: directoryPath,
278 kind: "directory",
279 parentPath: parentPathOf(directoryPath),
280 }),
281 )
282 .map(toSearchableWorkspaceEntry);
283 const fileEntries = [...new Set(filePaths)]
284 .toSorted((left, right) => left.localeCompare(right))
285 .map(
286 (filePath): ProjectEntry => ({
287 path: filePath,
288 kind: "file",
289 parentPath: parentPathOf(filePath),
290 }),
291 )
292 .map(toSearchableWorkspaceEntry);
293
294 const entries = [...directoryEntries, ...fileEntries];
295 return {
296 scannedAt: Date.now(),
297 entries: entries.slice(0, WORKSPACE_INDEX_MAX_ENTRIES),
298 truncated: listedFiles.truncated || entries.length > WORKSPACE_INDEX_MAX_ENTRIES,
299 };
300 },
301 );
302
303 const readDirectoryEntries = Effect.fn("WorkspaceEntries.readDirectoryEntries")(function* (
304 cwd: string,
305 relativeDir: string,
306 ): Effect.fn.Return<
307 { readonly relativeDir: string; readonly dirents: Dirent[] | null },
308 WorkspaceEntriesError
309 > {
310 return yield* Effect.tryPromise({
311 try: async () => {
312 const absoluteDir = relativeDir ? path.join(cwd, relativeDir) : cwd;
313 const dirents = await fsPromises.readdir(absoluteDir, { withFileTypes: true });
314 return { relativeDir, dirents };
315 },
316 catch: (cause) =>
317 new WorkspaceEntriesError({
318 cwd,
319 operation: "workspaceEntries.readDirectoryEntries",
320 detail: processErrorDetail(cause),
321 cause,
322 }),
323 }).pipe(
324 Effect.catchIf(
325 () => relativeDir.length > 0,
326 () => Effect.succeed({ relativeDir, dirents: null }),
327 ),
328 );
329 });
330
331 const buildWorkspaceIndexFromFilesystem = Effect.fn(
332 "WorkspaceEntries.buildWorkspaceIndexFromFilesystem",
333 )(function* (cwd: string): Effect.fn.Return<WorkspaceIndex, WorkspaceEntriesError> {
334 const shouldFilterWithGitIgnore = yield* isInsideGitWorkTree(cwd);
335
336 let pendingDirectories: string[] = [""];
337 const entries: SearchableWorkspaceEntry[] = [];
338 let truncated = false;
339
340 while (pendingDirectories.length > 0 && !truncated) {
341 const currentDirectories = pendingDirectories;
342 pendingDirectories = [];
343
344 const directoryEntries = yield* Effect.forEach(
345 currentDirectories,
346 (relativeDir) => readDirectoryEntries(cwd, relativeDir),
347 { concurrency: WORKSPACE_SCAN_READDIR_CONCURRENCY },
348 );
349
350 const candidateEntriesByDirectory = directoryEntries.map((directoryEntry) => {
351 const { relativeDir, dirents } = directoryEntry;
352 if (!dirents) return [] as Array<{ dirent: Dirent; relativePath: string }>;
353
354 dirents.sort((left, right) => left.name.localeCompare(right.name));
355 const candidates: Array<{ dirent: Dirent; relativePath: string }> = [];
356 for (const dirent of dirents) {
357 if (!dirent.name || dirent.name === "." || dirent.name === "..") {
358 continue;
359 }
360 if (dirent.isDirectory() && IGNORED_DIRECTORY_NAMES.has(dirent.name)) {
361 continue;
362 }
363 if (!dirent.isDirectory() && !dirent.isFile()) {
364 continue;
365 }
366
367 const relativePath = toPosixPath(
368 relativeDir ? path.join(relativeDir, dirent.name) : dirent.name,
369 );
370 if (isPathInIgnoredDirectory(relativePath)) {
371 continue;
372 }
373 candidates.push({ dirent, relativePath });
374 }
375 return candidates;
376 });
377
378 const candidatePaths = candidateEntriesByDirectory.flatMap((candidateEntries) =>
379 candidateEntries.map((entry) => entry.relativePath),
380 );
381 const allowedPathSet = shouldFilterWithGitIgnore
382 ? new Set(yield* filterGitIgnoredPaths(cwd, candidatePaths))
383 : null;
384
385 for (const candidateEntries of candidateEntriesByDirectory) {
386 for (const candidate of candidateEntries) {
387 if (allowedPathSet && !allowedPathSet.has(candidate.relativePath)) {
388 continue;
389 }
390
391 const entry = toSearchableWorkspaceEntry({
392 path: candidate.relativePath,
393 kind: candidate.dirent.isDirectory() ? "directory" : "file",
394 parentPath: parentPathOf(candidate.relativePath),
395 });
396 entries.push(entry);
397
398 if (candidate.dirent.isDirectory()) {
399 pendingDirectories.push(candidate.relativePath);
400 }
401
402 if (entries.length >= WORKSPACE_INDEX_MAX_ENTRIES) {
403 truncated = true;
404 break;
405 }
406 }
407
408 if (truncated) {
409 break;
410 }
411 }
412 }
413
414 return {
415 scannedAt: Date.now(),
416 entries,
417 truncated,
418 };
419 });
420
421 const buildWorkspaceIndex = Effect.fn("WorkspaceEntries.buildWorkspaceIndex")(function* (
422 cwd: string,
423 ): Effect.fn.Return<WorkspaceIndex, WorkspaceEntriesError> {
424 const gitIndexed = yield* buildWorkspaceIndexFromGit(cwd);
425 if (gitIndexed) {
426 return gitIndexed;
427 }
428 return yield* buildWorkspaceIndexFromFilesystem(cwd);
429 });
430
431 const workspaceIndexCache = yield* Cache.makeWith<string, WorkspaceIndex, WorkspaceEntriesError>({
432 capacity: WORKSPACE_CACHE_MAX_KEYS,
433 lookup: buildWorkspaceIndex,
434 timeToLive: (exit) =>
435 Exit.isSuccess(exit) ? Duration.millis(WORKSPACE_CACHE_TTL_MS) : Duration.zero,
436 });
437
438 const invalidate: WorkspaceEntriesShape["invalidate"] = Effect.fn("WorkspaceEntries.invalidate")(
439 function* (cwd) {
440 return yield* Cache.invalidate(workspaceIndexCache, cwd);
441 },
442 );
443
444 const search: WorkspaceEntriesShape["search"] = Effect.fn("WorkspaceEntries.search")(
445 function* (input) {
446 return yield* Cache.get(workspaceIndexCache, input.cwd).pipe(
447 Effect.map((index) => {
448 const normalizedQuery = normalizeQuery(input.query);
449 const limit = Math.max(0, Math.floor(input.limit));
450 const rankedEntries: RankedWorkspaceEntry[] = [];
451 let matchedEntryCount = 0;
452
453 for (const entry of index.entries) {
454 const score = scoreEntry(entry, normalizedQuery);
455 if (score === null) {
456 continue;
457 }
458
459 matchedEntryCount += 1;
460 insertRankedEntry(rankedEntries, { entry, score }, limit);
461 }
462
463 return {
464 entries: rankedEntries.map((candidate) => candidate.entry),
465 truncated: index.truncated || matchedEntryCount > limit,
466 };
467 }),
468 );
469 },
470 );
471
472 return {
473 invalidate,
474 search,
475 } satisfies WorkspaceEntriesShape;
476});
477
478export const WorkspaceEntriesLive = Layer.effect(WorkspaceEntries, makeWorkspaceEntries);
apps/server/src/workspace/Layers/WorkspaceFileSystem.test.ts created+135
...@@ -0,0 +1,135 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { it, describe, expect } from "@effect/vitest";
3import { Effect, FileSystem, Layer, Path } from "effect";
4
5import { ServerConfig } from "../../config.ts";
6import { GitCoreLive } from "../../git/Layers/GitCore.ts";
7import { WorkspaceEntries } from "../Services/WorkspaceEntries.ts";
8import { WorkspaceFileSystem } from "../Services/WorkspaceFileSystem.ts";
9import { WorkspaceEntriesLive } from "./WorkspaceEntries.ts";
10import { WorkspaceFileSystemLive } from "./WorkspaceFileSystem.ts";
11import { WorkspacePathsLive } from "./WorkspacePaths.ts";
12
13const ProjectLayer = WorkspaceFileSystemLive.pipe(
14 Layer.provide(WorkspacePathsLive),
15 Layer.provide(WorkspaceEntriesLive),
16);
17
18const TestLayer = Layer.empty.pipe(
19 Layer.provideMerge(ProjectLayer),
20 Layer.provideMerge(WorkspaceEntriesLive),
21 Layer.provideMerge(GitCoreLive),
22 Layer.provide(
23 ServerConfig.layerTest(process.cwd(), {
24 prefix: "t3-workspace-files-test-",
25 }),
26 ),
27 Layer.provideMerge(NodeServices.layer),
28);
29
30const makeTempDir = Effect.gen(function* () {
31 const fileSystem = yield* FileSystem.FileSystem;
32 return yield* fileSystem.makeTempDirectoryScoped({
33 prefix: "t3code-workspace-files-",
34 });
35});
36
37const writeTextFile = Effect.fn("writeTextFile")(function* (
38 cwd: string,
39 relativePath: string,
40 contents = "",
41) {
42 const fileSystem = yield* FileSystem.FileSystem;
43 const path = yield* Path.Path;
44 const absolutePath = path.join(cwd, relativePath);
45 yield* fileSystem
46 .makeDirectory(path.dirname(absolutePath), { recursive: true })
47 .pipe(Effect.orDie);
48 yield* fileSystem.writeFileString(absolutePath, contents).pipe(Effect.orDie);
49});
50
51it.layer(TestLayer)("WorkspaceFileSystemLive", (it) => {
52 describe("writeFile", () => {
53 it.effect("writes files relative to the workspace root", () =>
54 Effect.gen(function* () {
55 const workspaceFileSystem = yield* WorkspaceFileSystem;
56 const cwd = yield* makeTempDir;
57 const fileSystem = yield* FileSystem.FileSystem;
58 const path = yield* Path.Path;
59 const result = yield* workspaceFileSystem.writeFile({
60 cwd,
61 relativePath: "plans/effect-rpc.md",
62 contents: "# Plan\n",
63 });
64 const saved = yield* fileSystem
65 .readFileString(path.join(cwd, "plans/effect-rpc.md"))
66 .pipe(Effect.orDie);
67
68 expect(result).toEqual({ relativePath: "plans/effect-rpc.md" });
69 expect(saved).toBe("# Plan\n");
70 }),
71 );
72
73 it.effect("invalidates workspace entry search cache after writes", () =>
74 Effect.gen(function* () {
75 const workspaceEntries = yield* WorkspaceEntries;
76 const workspaceFileSystem = yield* WorkspaceFileSystem;
77 const cwd = yield* makeTempDir;
78 yield* writeTextFile(cwd, "src/existing.ts", "export {};\n");
79
80 const beforeWrite = yield* workspaceEntries.search({
81 cwd,
82 query: "rpc",
83 limit: 10,
84 });
85 expect(beforeWrite).toEqual({
86 entries: [],
87 truncated: false,
88 });
89
90 yield* workspaceFileSystem.writeFile({
91 cwd,
92 relativePath: "plans/effect-rpc.md",
93 contents: "# Plan\n",
94 });
95
96 const afterWrite = yield* workspaceEntries.search({
97 cwd,
98 query: "rpc",
99 limit: 10,
100 });
101 expect(afterWrite.entries).toEqual(
102 expect.arrayContaining([expect.objectContaining({ path: "plans/effect-rpc.md" })]),
103 );
104 expect(afterWrite.truncated).toBe(false);
105 }),
106 );
107
108 it.effect("rejects writes outside the workspace root", () =>
109 Effect.gen(function* () {
110 const workspaceFileSystem = yield* WorkspaceFileSystem;
111 const cwd = yield* makeTempDir;
112 const path = yield* Path.Path;
113 const fileSystem = yield* FileSystem.FileSystem;
114
115 const error = yield* workspaceFileSystem
116 .writeFile({
117 cwd,
118 relativePath: "../escape.md",
119 contents: "# nope\n",
120 })
121 .pipe(Effect.flip);
122
123 expect(error.message).toContain(
124 "Workspace file path must be relative to the project root: ../escape.md",
125 );
126
127 const escapedPath = path.resolve(cwd, "..", "escape.md");
128 const escapedStat = yield* fileSystem
129 .stat(escapedPath)
130 .pipe(Effect.catch(() => Effect.succeed(null)));
131 expect(escapedStat).toBeNull();
132 }),
133 );
134 });
135});
apps/server/src/workspace/Layers/WorkspaceFileSystem.ts created+55
...@@ -0,0 +1,55 @@
1import { Effect, FileSystem, Layer, Path } from "effect";
2
3import {
4 WorkspaceFileSystem,
5 WorkspaceFileSystemError,
6 type WorkspaceFileSystemShape,
7} from "../Services/WorkspaceFileSystem.ts";
8import { WorkspaceEntries } from "../Services/WorkspaceEntries.ts";
9import { WorkspacePaths } from "../Services/WorkspacePaths.ts";
10
11export const makeWorkspaceFileSystem = Effect.gen(function* () {
12 const fileSystem = yield* FileSystem.FileSystem;
13 const path = yield* Path.Path;
14 const workspacePaths = yield* WorkspacePaths;
15 const workspaceEntries = yield* WorkspaceEntries;
16
17 const writeFile: WorkspaceFileSystemShape["writeFile"] = Effect.fn(
18 "WorkspaceFileSystem.writeFile",
19 )(function* (input) {
20 const target = yield* workspacePaths.resolveRelativePathWithinRoot({
21 workspaceRoot: input.cwd,
22 relativePath: input.relativePath,
23 });
24
25 yield* fileSystem.makeDirectory(path.dirname(target.absolutePath), { recursive: true }).pipe(
26 Effect.mapError(
27 (cause) =>
28 new WorkspaceFileSystemError({
29 cwd: input.cwd,
30 relativePath: input.relativePath,
31 operation: "workspaceFileSystem.makeDirectory",
32 detail: cause.message,
33 cause,
34 }),
35 ),
36 );
37 yield* fileSystem.writeFileString(target.absolutePath, input.contents).pipe(
38 Effect.mapError(
39 (cause) =>
40 new WorkspaceFileSystemError({
41 cwd: input.cwd,
42 relativePath: input.relativePath,
43 operation: "workspaceFileSystem.writeFile",
44 detail: cause.message,
45 cause,
46 }),
47 ),
48 );
49 yield* workspaceEntries.invalidate(input.cwd);
50 return { relativePath: target.relativePath };
51 });
52 return { writeFile } satisfies WorkspaceFileSystemShape;
53});
54
55export const WorkspaceFileSystemLive = Layer.effect(WorkspaceFileSystem, makeWorkspaceFileSystem);
apps/server/src/workspace/Layers/WorkspacePaths.test.ts created+113
...@@ -0,0 +1,113 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { it, describe, expect } from "@effect/vitest";
3import { Effect, FileSystem, Layer, Path } from "effect";
4
5import { WorkspacePaths } from "../Services/WorkspacePaths.ts";
6import { WorkspacePathsLive } from "./WorkspacePaths.ts";
7
8const TestLayer = Layer.empty.pipe(
9 Layer.provideMerge(WorkspacePathsLive),
10 Layer.provideMerge(NodeServices.layer),
11);
12
13const makeTempDir = Effect.fn("makeTempDir")(function* () {
14 const fileSystem = yield* FileSystem.FileSystem;
15 return yield* fileSystem.makeTempDirectoryScoped({
16 prefix: "t3code-project-paths-",
17 });
18});
19
20const writeTextFile = Effect.fn("writeTextFile")(function* (
21 cwd: string,
22 relativePath: string,
23 contents = "",
24) {
25 const fileSystem = yield* FileSystem.FileSystem;
26 const path = yield* Path.Path;
27 const absolutePath = path.join(cwd, relativePath);
28 yield* fileSystem
29 .makeDirectory(path.dirname(absolutePath), { recursive: true })
30 .pipe(Effect.orDie);
31 yield* fileSystem.writeFileString(absolutePath, contents).pipe(Effect.orDie);
32});
33
34it.layer(TestLayer)("WorkspacePathsLive", (it) => {
35 describe("normalizeWorkspaceRoot", () => {
36 it.effect("resolves an existing directory", () =>
37 Effect.gen(function* () {
38 const workspacePaths = yield* WorkspacePaths;
39 const cwd = yield* makeTempDir();
40
41 const resolved = yield* workspacePaths.normalizeWorkspaceRoot(cwd);
42
43 expect(resolved).toBe(cwd);
44 }),
45 );
46
47 it.effect("rejects missing directories", () =>
48 Effect.gen(function* () {
49 const workspacePaths = yield* WorkspacePaths;
50 const cwd = yield* makeTempDir();
51 const path = yield* Path.Path;
52
53 const error = yield* workspacePaths
54 .normalizeWorkspaceRoot(path.join(cwd, "missing"))
55 .pipe(Effect.flip);
56
57 expect(error.message).toContain("Workspace root does not exist:");
58 }),
59 );
60
61 it.effect("rejects file paths", () =>
62 Effect.gen(function* () {
63 const workspacePaths = yield* WorkspacePaths;
64 const cwd = yield* makeTempDir();
65 const path = yield* Path.Path;
66 const filePath = path.join(cwd, "README.md");
67 yield* writeTextFile(cwd, "README.md", "# hi\n");
68
69 const error = yield* workspacePaths.normalizeWorkspaceRoot(filePath).pipe(Effect.flip);
70
71 expect(error.message).toContain("Workspace root is not a directory:");
72 }),
73 );
74 });
75
76 describe("resolveRelativePathWithinRoot", () => {
77 it.effect("resolves relative paths inside the workspace root", () =>
78 Effect.gen(function* () {
79 const workspacePaths = yield* WorkspacePaths;
80 const cwd = yield* makeTempDir();
81 const path = yield* Path.Path;
82
83 const resolved = yield* workspacePaths.resolveRelativePathWithinRoot({
84 workspaceRoot: cwd,
85 relativePath: "plans/effect-rpc.md",
86 });
87
88 expect(resolved).toEqual({
89 absolutePath: path.join(cwd, "plans/effect-rpc.md"),
90 relativePath: "plans/effect-rpc.md",
91 });
92 }),
93 );
94
95 it.effect("rejects paths that escape the workspace root", () =>
96 Effect.gen(function* () {
97 const workspacePaths = yield* WorkspacePaths;
98 const cwd = yield* makeTempDir();
99
100 const error = yield* workspacePaths
101 .resolveRelativePathWithinRoot({
102 workspaceRoot: cwd,
103 relativePath: "../escape.md",
104 })
105 .pipe(Effect.flip);
106
107 expect(error.message).toContain(
108 "Workspace file path must be relative to the project root: ../escape.md",
109 );
110 }),
111 );
112 });
113});
apps/server/src/workspace/Layers/WorkspacePaths.ts created+89
...@@ -0,0 +1,89 @@
1import * as OS from "node:os";
2import { Effect, FileSystem, Layer, Path } from "effect";
3
4import {
5 WorkspacePaths,
6 WorkspacePathOutsideRootError,
7 WorkspaceRootNotDirectoryError,
8 WorkspaceRootNotExistsError,
9 type WorkspacePathsShape,
10} from "../Services/WorkspacePaths.ts";
11
12function toPosixRelativePath(input: string): string {
13 return input.replaceAll("\\", "/");
14}
15
16function expandHomePath(input: string, path: Path.Path): string {
17 if (input === "~") {
18 return OS.homedir();
19 }
20 if (input.startsWith("~/") || input.startsWith("~\\")) {
21 return path.join(OS.homedir(), input.slice(2));
22 }
23 return input;
24}
25
26export const makeWorkspacePaths = Effect.gen(function* () {
27 const fileSystem = yield* FileSystem.FileSystem;
28 const path = yield* Path.Path;
29
30 const normalizeWorkspaceRoot: WorkspacePathsShape["normalizeWorkspaceRoot"] = Effect.fn(
31 "WorkspacePaths.normalizeWorkspaceRoot",
32 )(function* (workspaceRoot) {
33 const normalizedWorkspaceRoot = path.resolve(expandHomePath(workspaceRoot.trim(), path));
34 const workspaceStat = yield* fileSystem
35 .stat(normalizedWorkspaceRoot)
36 .pipe(Effect.catch(() => Effect.succeed(null)));
37 if (!workspaceStat) {
38 return yield* new WorkspaceRootNotExistsError({
39 workspaceRoot,
40 normalizedWorkspaceRoot,
41 });
42 }
43 if (workspaceStat.type !== "Directory") {
44 return yield* new WorkspaceRootNotDirectoryError({
45 workspaceRoot,
46 normalizedWorkspaceRoot,
47 });
48 }
49 return normalizedWorkspaceRoot;
50 });
51
52 const resolveRelativePathWithinRoot: WorkspacePathsShape["resolveRelativePathWithinRoot"] =
53 Effect.fn("WorkspacePaths.resolveRelativePathWithinRoot")(function* (input) {
54 const normalizedInputPath = input.relativePath.trim();
55 if (path.isAbsolute(normalizedInputPath)) {
56 return yield* new WorkspacePathOutsideRootError({
57 workspaceRoot: input.workspaceRoot,
58 relativePath: input.relativePath,
59 });
60 }
61
62 const absolutePath = path.resolve(input.workspaceRoot, normalizedInputPath);
63 const relativeToRoot = toPosixRelativePath(path.relative(input.workspaceRoot, absolutePath));
64 if (
65 relativeToRoot.length === 0 ||
66 relativeToRoot === "." ||
67 relativeToRoot.startsWith("../") ||
68 relativeToRoot === ".." ||
69 path.isAbsolute(relativeToRoot)
70 ) {
71 return yield* new WorkspacePathOutsideRootError({
72 workspaceRoot: input.workspaceRoot,
73 relativePath: input.relativePath,
74 });
75 }
76
77 return {
78 absolutePath,
79 relativePath: relativeToRoot,
80 };
81 });
82
83 return {
84 normalizeWorkspaceRoot,
85 resolveRelativePathWithinRoot,
86 } satisfies WorkspacePathsShape;
87});
88
89export const WorkspacePathsLive = Layer.effect(WorkspacePaths, makeWorkspacePaths);
apps/server/src/workspace/Services/WorkspaceEntries.ts created+48
...@@ -0,0 +1,48 @@
1/**
2 * WorkspaceEntries - Effect service contract for cached workspace entry search.
3 *
4 * Owns indexed workspace entry search plus cache invalidation for workspace
5 * roots when the underlying filesystem changes.
6 *
7 * @module WorkspaceEntries
8 */
9import { Schema, ServiceMap } from "effect";
10import type { Effect } from "effect";
11
12import type { ProjectSearchEntriesInput, ProjectSearchEntriesResult } from "@t3tools/contracts";
13
14export class WorkspaceEntriesError extends Schema.TaggedErrorClass<WorkspaceEntriesError>()(
15 "WorkspaceEntriesError",
16 {
17 cwd: Schema.String,
18 operation: Schema.String,
19 detail: Schema.String,
20 cause: Schema.optional(Schema.Defect),
21 },
22) {}
23
24/**
25 * WorkspaceEntriesShape - Service API for workspace entry search and cache
26 * invalidation.
27 */
28export interface WorkspaceEntriesShape {
29 /**
30 * Search indexed workspace entries for files and directories matching the
31 * provided query.
32 */
33 readonly search: (
34 input: ProjectSearchEntriesInput,
35 ) => Effect.Effect<ProjectSearchEntriesResult, WorkspaceEntriesError>;
36
37 /**
38 * Drop any cached workspace entries for the given workspace root.
39 */
40 readonly invalidate: (cwd: string) => Effect.Effect<void>;
41}
42
43/**
44 * WorkspaceEntries - Service tag for cached workspace entry search.
45 */
46export class WorkspaceEntries extends ServiceMap.Service<WorkspaceEntries, WorkspaceEntriesShape>()(
47 "t3/workspace/Services/WorkspaceEntries",
48) {}
apps/server/src/workspace/Services/WorkspaceFileSystem.ts created+50
...@@ -0,0 +1,50 @@
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 { Schema, ServiceMap } from "effect";
10import type { Effect } from "effect";
11
12import type { ProjectWriteFileInput, ProjectWriteFileResult } from "@t3tools/contracts";
13import { WorkspacePathOutsideRootError } from "./WorkspacePaths.ts";
14
15export class WorkspaceFileSystemError extends Schema.TaggedErrorClass<WorkspaceFileSystemError>()(
16 "WorkspaceFileSystemError",
17 {
18 cwd: Schema.String,
19 relativePath: Schema.optional(Schema.String),
20 operation: Schema.String,
21 detail: Schema.String,
22 cause: Schema.optional(Schema.Defect),
23 },
24) {}
25
26/**
27 * WorkspaceFileSystemShape - Service API for workspace-relative file operations.
28 */
29export interface WorkspaceFileSystemShape {
30 /**
31 * Write a file relative to the workspace root.
32 *
33 * Creates parent directories as needed and rejects paths that escape the
34 * workspace root.
35 */
36 readonly writeFile: (
37 input: ProjectWriteFileInput,
38 ) => Effect.Effect<
39 ProjectWriteFileResult,
40 WorkspaceFileSystemError | WorkspacePathOutsideRootError
41 >;
42}
43
44/**
45 * WorkspaceFileSystem - Service tag for workspace file operations.
46 */
47export class WorkspaceFileSystem extends ServiceMap.Service<
48 WorkspaceFileSystem,
49 WorkspaceFileSystemShape
50>()("t3/workspace/Services/WorkspaceFileSystem") {}
apps/server/src/workspace/Services/WorkspacePaths.ts created+85
...@@ -0,0 +1,85 @@
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 { Schema, ServiceMap } from "effect";
10import type { Effect } from "effect";
11
12export class WorkspaceRootNotExistsError extends Schema.TaggedErrorClass<WorkspaceRootNotExistsError>()(
13 "WorkspaceRootNotExistsError",
14 {
15 workspaceRoot: Schema.String,
16 normalizedWorkspaceRoot: Schema.String,
17 },
18) {
19 override get message(): string {
20 return `Workspace root does not exist: ${this.normalizedWorkspaceRoot}`;
21 }
22}
23
24export class WorkspaceRootNotDirectoryError extends Schema.TaggedErrorClass<WorkspaceRootNotDirectoryError>()(
25 "WorkspaceRootNotDirectoryError",
26 {
27 workspaceRoot: Schema.String,
28 normalizedWorkspaceRoot: Schema.String,
29 },
30) {
31 override get message(): string {
32 return `Workspace root is not a directory: ${this.normalizedWorkspaceRoot}`;
33 }
34}
35
36export class WorkspacePathOutsideRootError extends Schema.TaggedErrorClass<WorkspacePathOutsideRootError>()(
37 "WorkspacePathOutsideRootError",
38 {
39 workspaceRoot: Schema.String,
40 relativePath: Schema.String,
41 },
42) {
43 override get message(): string {
44 return `Workspace file path must be relative to the project root: ${this.relativePath}`;
45 }
46}
47
48export const WorkspacePathsError = Schema.Union([
49 WorkspaceRootNotExistsError,
50 WorkspaceRootNotDirectoryError,
51 WorkspacePathOutsideRootError,
52]);
53export type WorkspacePathsError = typeof WorkspacePathsError.Type;
54
55/**
56 * WorkspacePathsShape - Service API for workspace path normalization and guards.
57 */
58export interface WorkspacePathsShape {
59 /**
60 * Normalize a user-provided workspace root and verify it exists as a directory.
61 */
62 readonly normalizeWorkspaceRoot: (
63 workspaceRoot: string,
64 ) => Effect.Effect<string, WorkspaceRootNotExistsError | WorkspaceRootNotDirectoryError>;
65
66 /**
67 * Resolve a relative path within a validated workspace root.
68 *
69 * Rejects absolute paths and traversal attempts outside the workspace root.
70 */
71 readonly resolveRelativePathWithinRoot: (input: {
72 workspaceRoot: string;
73 relativePath: string;
74 }) => Effect.Effect<
75 { absolutePath: string; relativePath: string },
76 WorkspacePathOutsideRootError
77 >;
78}
79
80/**
81 * WorkspacePaths - Service tag for workspace path normalization and resolution.
82 */
83export class WorkspacePaths extends ServiceMap.Service<WorkspacePaths, WorkspacePathsShape>()(
84 "t3/workspace/Services/WorkspacePaths",
85) {}
apps/server/src/workspaceEntries.chunking.test.ts deleted-81
...@@ -1,81 +0,0 @@
1import { assert, beforeEach, describe, it, vi } from "vitest";
2import type { ProcessRunOptions, ProcessRunResult } from "./processRunner";
3
4const { runProcessMock } = vi.hoisted(() => ({
5 runProcessMock:
6 vi.fn<
7 (
8 command: string,
9 args: readonly string[],
10 options?: ProcessRunOptions,
11 ) => Promise<ProcessRunResult>
12 >(),
13}));
14
15vi.mock("./processRunner", () => ({
16 runProcess: runProcessMock,
17}));
18
19function processResult(
20 overrides: Partial<ProcessRunResult> & Pick<ProcessRunResult, "stdout" | "code">,
21): ProcessRunResult {
22 return {
23 stdout: overrides.stdout,
24 code: overrides.code,
25 stderr: overrides.stderr ?? "",
26 signal: overrides.signal ?? null,
27 timedOut: overrides.timedOut ?? false,
28 stdoutTruncated: overrides.stdoutTruncated ?? false,
29 stderrTruncated: overrides.stderrTruncated ?? false,
30 };
31}
32
33describe("searchWorkspaceEntries git-ignore chunking", () => {
34 beforeEach(() => {
35 runProcessMock.mockReset();
36 vi.resetModules();
37 });
38
39 it("chunks git check-ignore stdin to avoid building giant strings", async () => {
40 const ignoredPaths = Array.from(
41 { length: 5000 },
42 (_, index) => `ignored/${index.toString().padStart(5, "0")}/${"x".repeat(80)}.ts`,
43 );
44 const keptPaths = ["src/keep.ts", "docs/readme.md"];
45 const listedPaths = [...ignoredPaths, ...keptPaths];
46 let checkIgnoreCalls = 0;
47
48 runProcessMock.mockImplementation(async (_command, args, options) => {
49 if (args[0] === "rev-parse") {
50 return processResult({ code: 0, stdout: "true\n" });
51 }
52
53 if (args[0] === "ls-files") {
54 return processResult({ code: 0, stdout: `${listedPaths.join("\0")}\0` });
55 }
56
57 if (args[0] === "check-ignore") {
58 checkIgnoreCalls += 1;
59 const chunkPaths = (options?.stdin ?? "").split("\0").filter((value) => value.length > 0);
60 const chunkIgnored = chunkPaths.filter((value) => value.startsWith("ignored/"));
61 return processResult({
62 code: chunkIgnored.length > 0 ? 0 : 1,
63 stdout: chunkIgnored.length > 0 ? `${chunkIgnored.join("\0")}\0` : "",
64 });
65 }
66
67 throw new Error(`Unexpected command: git ${args.join(" ")}`);
68 });
69
70 const { searchWorkspaceEntries } = await import("./workspaceEntries");
71 const result = await searchWorkspaceEntries({
72 cwd: "/virtual/workspace",
73 query: "",
74 limit: 100,
75 });
76
77 assert.isAbove(checkIgnoreCalls, 1);
78 assert.isFalse(result.entries.some((entry) => entry.path.startsWith("ignored/")));
79 assert.isTrue(result.entries.some((entry) => entry.path === "src/keep.ts"));
80 });
81});
apps/server/src/workspaceEntries.test.ts deleted-202
...@@ -1,202 +0,0 @@
1import fs from "node:fs";
2import fsPromises from "node:fs/promises";
3import os from "node:os";
4import path from "node:path";
5import { spawnSync } from "node:child_process";
6
7import { afterEach, assert, describe, it, vi } from "vitest";
8
9import { searchWorkspaceEntries } from "./workspaceEntries";
10
11const tempDirs: string[] = [];
12
13function makeTempDir(prefix: string): string {
14 const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
15 tempDirs.push(dir);
16 return dir;
17}
18
19function writeFile(cwd: string, relativePath: string, contents = ""): void {
20 const absolutePath = path.join(cwd, relativePath);
21 fs.mkdirSync(path.dirname(absolutePath), { recursive: true });
22 fs.writeFileSync(absolutePath, contents, "utf8");
23}
24
25function runGit(cwd: string, args: string[]): void {
26 const result = spawnSync("git", args, { cwd, encoding: "utf8" });
27 if (result.status !== 0) {
28 throw new Error(result.stderr || `git ${args.join(" ")} failed`);
29 }
30}
31
32describe("searchWorkspaceEntries", () => {
33 afterEach(() => {
34 vi.restoreAllMocks();
35 for (const dir of tempDirs.splice(0, tempDirs.length)) {
36 fs.rmSync(dir, { recursive: true, force: true });
37 }
38 });
39
40 it("returns files and directories relative to cwd", async () => {
41 const cwd = makeTempDir("t3code-workspace-entries-");
42 writeFile(cwd, "src/components/Composer.tsx");
43 writeFile(cwd, "src/index.ts");
44 writeFile(cwd, "README.md");
45 writeFile(cwd, ".git/HEAD");
46 writeFile(cwd, "node_modules/pkg/index.js");
47
48 const result = await searchWorkspaceEntries({ cwd, query: "", limit: 100 });
49 const paths = result.entries.map((entry) => entry.path);
50
51 assert.include(paths, "src");
52 assert.include(paths, "src/components");
53 assert.include(paths, "src/components/Composer.tsx");
54 assert.include(paths, "README.md");
55 assert.isFalse(paths.some((entryPath) => entryPath.startsWith(".git")));
56 assert.isFalse(paths.some((entryPath) => entryPath.startsWith("node_modules")));
57 assert.isFalse(result.truncated);
58 });
59
60 it("filters and ranks entries by query", async () => {
61 const cwd = makeTempDir("t3code-workspace-query-");
62 writeFile(cwd, "src/components/Composer.tsx");
63 writeFile(cwd, "src/components/composePrompt.ts");
64 writeFile(cwd, "docs/composition.md");
65
66 const result = await searchWorkspaceEntries({ cwd, query: "compo", limit: 5 });
67
68 assert.isAbove(result.entries.length, 0);
69 assert.isTrue(result.entries.some((entry) => entry.path === "src/components"));
70 assert.isTrue(result.entries.every((entry) => entry.path.toLowerCase().includes("compo")));
71 });
72
73 it("supports fuzzy subsequence queries for composer path search", async () => {
74 const cwd = makeTempDir("t3code-workspace-fuzzy-query-");
75 writeFile(cwd, "src/components/Composer.tsx");
76 writeFile(cwd, "src/components/composePrompt.ts");
77 writeFile(cwd, "docs/composition.md");
78
79 const result = await searchWorkspaceEntries({ cwd, query: "cmp", limit: 10 });
80 const paths = result.entries.map((entry) => entry.path);
81
82 assert.isAbove(result.entries.length, 0);
83 assert.include(paths, "src/components");
84 assert.include(paths, "src/components/Composer.tsx");
85 });
86
87 it("tracks truncation without sorting every fuzzy match", async () => {
88 const cwd = makeTempDir("t3code-workspace-fuzzy-limit-");
89 writeFile(cwd, "src/components/Composer.tsx");
90 writeFile(cwd, "src/components/composePrompt.ts");
91 writeFile(cwd, "docs/composition.md");
92
93 const result = await searchWorkspaceEntries({ cwd, query: "cmp", limit: 1 });
94
95 assert.lengthOf(result.entries, 1);
96 assert.isTrue(result.truncated);
97 });
98
99 it("excludes gitignored paths for git repositories", async () => {
100 const cwd = makeTempDir("t3code-workspace-gitignore-");
101 runGit(cwd, ["init"]);
102 writeFile(cwd, ".gitignore", ".convex/\nconvex/\nignored.txt\n");
103 writeFile(cwd, "src/keep.ts", "export {};");
104 writeFile(cwd, "ignored.txt", "ignore me");
105 writeFile(cwd, ".convex/local-storage/data.json", "{}");
106 writeFile(cwd, "convex/UOoS-l/convex_local_storage/modules/data.json", "{}");
107
108 const result = await searchWorkspaceEntries({ cwd, query: "", limit: 100 });
109 const paths = result.entries.map((entry) => entry.path);
110
111 assert.include(paths, "src");
112 assert.include(paths, "src/keep.ts");
113 assert.notInclude(paths, "ignored.txt");
114 assert.isFalse(paths.some((entryPath) => entryPath.startsWith(".convex/")));
115 assert.isFalse(paths.some((entryPath) => entryPath.startsWith("convex/")));
116 });
117
118 it("excludes tracked paths that match ignore rules", async () => {
119 const cwd = makeTempDir("t3code-workspace-tracked-gitignore-");
120 runGit(cwd, ["init"]);
121 writeFile(cwd, ".convex/local-storage/data.json", "{}");
122 writeFile(cwd, "src/keep.ts", "export {};");
123 runGit(cwd, ["add", ".convex/local-storage/data.json", "src/keep.ts"]);
124 writeFile(cwd, ".gitignore", ".convex/\n");
125
126 const result = await searchWorkspaceEntries({ cwd, query: "", limit: 100 });
127 const paths = result.entries.map((entry) => entry.path);
128
129 assert.include(paths, "src");
130 assert.include(paths, "src/keep.ts");
131 assert.isFalse(paths.some((entryPath) => entryPath.startsWith(".convex/")));
132 });
133
134 it("excludes .convex in non-git workspaces", async () => {
135 const cwd = makeTempDir("t3code-workspace-non-git-convex-");
136 writeFile(cwd, ".convex/local-storage/data.json", "{}");
137 writeFile(cwd, "src/keep.ts", "export {};");
138
139 const result = await searchWorkspaceEntries({ cwd, query: "", limit: 100 });
140 const paths = result.entries.map((entry) => entry.path);
141
142 assert.include(paths, "src");
143 assert.include(paths, "src/keep.ts");
144 assert.isFalse(paths.some((entryPath) => entryPath.startsWith(".convex/")));
145 });
146
147 it("deduplicates concurrent index builds for the same cwd", async () => {
148 const cwd = makeTempDir("t3code-workspace-concurrent-build-");
149 writeFile(cwd, "src/components/Composer.tsx");
150
151 let rootReadCount = 0;
152 const originalReaddir = fsPromises.readdir.bind(fsPromises);
153 vi.spyOn(fsPromises, "readdir").mockImplementation((async (
154 ...args: Parameters<typeof fsPromises.readdir>
155 ) => {
156 if (args[0] === cwd) {
157 rootReadCount += 1;
158 await new Promise((resolve) => setTimeout(resolve, 20));
159 }
160 return originalReaddir(...args);
161 }) as typeof fsPromises.readdir);
162
163 await Promise.all([
164 searchWorkspaceEntries({ cwd, query: "", limit: 100 }),
165 searchWorkspaceEntries({ cwd, query: "comp", limit: 100 }),
166 searchWorkspaceEntries({ cwd, query: "src", limit: 100 }),
167 ]);
168
169 assert.equal(rootReadCount, 1);
170 });
171
172 it("limits concurrent directory reads while walking the filesystem", async () => {
173 const cwd = makeTempDir("t3code-workspace-read-concurrency-");
174 for (let index = 0; index < 80; index += 1) {
175 writeFile(cwd, `group-${index}/entry-${index}.ts`, "export {};");
176 }
177
178 let activeReads = 0;
179 let peakReads = 0;
180 const originalReaddir = fsPromises.readdir.bind(fsPromises);
181 vi.spyOn(fsPromises, "readdir").mockImplementation((async (
182 ...args: Parameters<typeof fsPromises.readdir>
183 ) => {
184 const target = args[0];
185 if (typeof target === "string" && target.startsWith(cwd)) {
186 activeReads += 1;
187 peakReads = Math.max(peakReads, activeReads);
188 await new Promise((resolve) => setTimeout(resolve, 4));
189 try {
190 return await originalReaddir(...args);
191 } finally {
192 activeReads -= 1;
193 }
194 }
195 return originalReaddir(...args);
196 }) as typeof fsPromises.readdir);
197
198 await searchWorkspaceEntries({ cwd, query: "", limit: 200 });
199
200 assert.isAtMost(peakReads, 32);
201 });
202});
apps/server/src/workspaceEntries.ts deleted-565
...@@ -1,565 +0,0 @@
1import fs from "node:fs/promises";
2import type { Dirent } from "node:fs";
3import path from "node:path";
4import { runProcess } from "./processRunner";
5
6import {
7 ProjectEntry,
8 ProjectSearchEntriesInput,
9 ProjectSearchEntriesResult,
10} from "@t3tools/contracts";
11
12const WORKSPACE_CACHE_TTL_MS = 15_000;
13const WORKSPACE_CACHE_MAX_KEYS = 4;
14const WORKSPACE_INDEX_MAX_ENTRIES = 25_000;
15const WORKSPACE_SCAN_READDIR_CONCURRENCY = 32;
16const GIT_CHECK_IGNORE_MAX_STDIN_BYTES = 256 * 1024;
17const IGNORED_DIRECTORY_NAMES = new Set([
18 ".git",
19 ".convex",
20 "node_modules",
21 ".next",
22 ".turbo",
23 "dist",
24 "build",
25 "out",
26 ".cache",
27]);
28
29interface WorkspaceIndex {
30 scannedAt: number;
31 entries: SearchableWorkspaceEntry[];
32 truncated: boolean;
33}
34
35interface SearchableWorkspaceEntry extends ProjectEntry {
36 normalizedPath: string;
37 normalizedName: string;
38}
39
40interface RankedWorkspaceEntry {
41 entry: SearchableWorkspaceEntry;
42 score: number;
43}
44
45const workspaceIndexCache = new Map<string, WorkspaceIndex>();
46const inFlightWorkspaceIndexBuilds = new Map<string, Promise<WorkspaceIndex>>();
47
48function toPosixPath(input: string): string {
49 return input.split(path.sep).join("/");
50}
51
52function parentPathOf(input: string): string | undefined {
53 const separatorIndex = input.lastIndexOf("/");
54 if (separatorIndex === -1) {
55 return undefined;
56 }
57 return input.slice(0, separatorIndex);
58}
59
60function basenameOf(input: string): string {
61 const separatorIndex = input.lastIndexOf("/");
62 if (separatorIndex === -1) {
63 return input;
64 }
65 return input.slice(separatorIndex + 1);
66}
67
68function toSearchableWorkspaceEntry(entry: ProjectEntry): SearchableWorkspaceEntry {
69 const normalizedPath = entry.path.toLowerCase();
70 return {
71 ...entry,
72 normalizedPath,
73 normalizedName: basenameOf(normalizedPath),
74 };
75}
76
77function normalizeQuery(input: string): string {
78 return input
79 .trim()
80 .replace(/^[@./]+/, "")
81 .toLowerCase();
82}
83
84function scoreSubsequenceMatch(value: string, query: string): number | null {
85 if (!query) return 0;
86
87 let queryIndex = 0;
88 let firstMatchIndex = -1;
89 let previousMatchIndex = -1;
90 let gapPenalty = 0;
91
92 for (let valueIndex = 0; valueIndex < value.length; valueIndex += 1) {
93 if (value[valueIndex] !== query[queryIndex]) {
94 continue;
95 }
96
97 if (firstMatchIndex === -1) {
98 firstMatchIndex = valueIndex;
99 }
100 if (previousMatchIndex !== -1) {
101 gapPenalty += valueIndex - previousMatchIndex - 1;
102 }
103
104 previousMatchIndex = valueIndex;
105 queryIndex += 1;
106 if (queryIndex === query.length) {
107 const spanPenalty = valueIndex - firstMatchIndex + 1 - query.length;
108 const lengthPenalty = Math.min(64, value.length - query.length);
109 return firstMatchIndex * 2 + gapPenalty * 3 + spanPenalty + lengthPenalty;
110 }
111 }
112
113 return null;
114}
115
116function scoreEntry(entry: SearchableWorkspaceEntry, query: string): number | null {
117 if (!query) {
118 return entry.kind === "directory" ? 0 : 1;
119 }
120
121 const { normalizedPath, normalizedName } = entry;
122
123 if (normalizedName === query) return 0;
124 if (normalizedPath === query) return 1;
125 if (normalizedName.startsWith(query)) return 2;
126 if (normalizedPath.startsWith(query)) return 3;
127 if (normalizedPath.includes(`/${query}`)) return 4;
128 if (normalizedName.includes(query)) return 5;
129 if (normalizedPath.includes(query)) return 6;
130
131 const nameFuzzyScore = scoreSubsequenceMatch(normalizedName, query);
132 if (nameFuzzyScore !== null) {
133 return 100 + nameFuzzyScore;
134 }
135
136 const pathFuzzyScore = scoreSubsequenceMatch(normalizedPath, query);
137 if (pathFuzzyScore !== null) {
138 return 200 + pathFuzzyScore;
139 }
140
141 return null;
142}
143
144function compareRankedWorkspaceEntries(
145 left: RankedWorkspaceEntry,
146 right: RankedWorkspaceEntry,
147): number {
148 const scoreDelta = left.score - right.score;
149 if (scoreDelta !== 0) return scoreDelta;
150 return left.entry.path.localeCompare(right.entry.path);
151}
152
153function findInsertionIndex(
154 rankedEntries: RankedWorkspaceEntry[],
155 candidate: RankedWorkspaceEntry,
156): number {
157 let low = 0;
158 let high = rankedEntries.length;
159
160 while (low < high) {
161 const middle = low + Math.floor((high - low) / 2);
162 const current = rankedEntries[middle];
163 if (!current) {
164 break;
165 }
166
167 if (compareRankedWorkspaceEntries(candidate, current) < 0) {
168 high = middle;
169 } else {
170 low = middle + 1;
171 }
172 }
173
174 return low;
175}
176
177function insertRankedEntry(
178 rankedEntries: RankedWorkspaceEntry[],
179 candidate: RankedWorkspaceEntry,
180 limit: number,
181): void {
182 if (limit <= 0) {
183 return;
184 }
185
186 const insertionIndex = findInsertionIndex(rankedEntries, candidate);
187 if (rankedEntries.length < limit) {
188 rankedEntries.splice(insertionIndex, 0, candidate);
189 return;
190 }
191
192 if (insertionIndex >= limit) {
193 return;
194 }
195
196 rankedEntries.splice(insertionIndex, 0, candidate);
197 rankedEntries.pop();
198}
199
200function isPathInIgnoredDirectory(relativePath: string): boolean {
201 const firstSegment = relativePath.split("/")[0];
202 if (!firstSegment) return false;
203 return IGNORED_DIRECTORY_NAMES.has(firstSegment);
204}
205
206function splitNullSeparatedPaths(input: string, truncated: boolean): string[] {
207 const parts = input.split("\0");
208 if (parts.length === 0) return [];
209
210 // If output was truncated, the final token can be partial.
211 if (truncated && parts[parts.length - 1]?.length) {
212 parts.pop();
213 }
214
215 return parts.filter((value) => value.length > 0);
216}
217
218function directoryAncestorsOf(relativePath: string): string[] {
219 const segments = relativePath.split("/").filter((segment) => segment.length > 0);
220 if (segments.length <= 1) return [];
221 const directories: string[] = [];
222 for (let index = 1; index < segments.length; index += 1) {
223 directories.push(segments.slice(0, index).join("/"));
224 }
225 return directories;
226}
227
228async function mapWithConcurrency<TInput, TOutput>(
229 items: readonly TInput[],
230 concurrency: number,
231 mapper: (item: TInput, index: number) => Promise<TOutput>,
232): Promise<TOutput[]> {
233 if (items.length === 0) {
234 return [];
235 }
236
237 const boundedConcurrency = Math.max(1, Math.min(concurrency, items.length));
238 const results = Array.from({ length: items.length }) as TOutput[];
239 let nextIndex = 0;
240
241 const workers = Array.from({ length: boundedConcurrency }, async () => {
242 while (nextIndex < items.length) {
243 const currentIndex = nextIndex;
244 nextIndex += 1;
245 results[currentIndex] = await mapper(items[currentIndex] as TInput, currentIndex);
246 }
247 });
248
249 await Promise.all(workers);
250 return results;
251}
252
253async function isInsideGitWorkTree(cwd: string): Promise<boolean> {
254 const insideWorkTree = await runProcess("git", ["rev-parse", "--is-inside-work-tree"], {
255 cwd,
256 allowNonZeroExit: true,
257 timeoutMs: 5_000,
258 maxBufferBytes: 4_096,
259 }).catch(() => null);
260 return Boolean(
261 insideWorkTree && insideWorkTree.code === 0 && insideWorkTree.stdout.trim() === "true",
262 );
263}
264
265async function filterGitIgnoredPaths(cwd: string, relativePaths: string[]): Promise<string[]> {
266 if (relativePaths.length === 0) {
267 return relativePaths;
268 }
269
270 const ignoredPaths = new Set<string>();
271 let chunk: string[] = [];
272 let chunkBytes = 0;
273
274 const flushChunk = async (): Promise<boolean> => {
275 if (chunk.length === 0) {
276 return true;
277 }
278
279 const checkIgnore = await runProcess("git", ["check-ignore", "--no-index", "-z", "--stdin"], {
280 cwd,
281 allowNonZeroExit: true,
282 timeoutMs: 20_000,
283 maxBufferBytes: 16 * 1024 * 1024,
284 outputMode: "truncate",
285 stdin: `${chunk.join("\0")}\0`,
286 }).catch(() => null);
287 chunk = [];
288 chunkBytes = 0;
289
290 if (!checkIgnore) {
291 return false;
292 }
293
294 // git-check-ignore exits with 1 when no paths match.
295 if (checkIgnore.code !== 0 && checkIgnore.code !== 1) {
296 return false;
297 }
298
299 const matchedIgnoredPaths = splitNullSeparatedPaths(
300 checkIgnore.stdout,
301 Boolean(checkIgnore.stdoutTruncated),
302 );
303 for (const ignoredPath of matchedIgnoredPaths) {
304 ignoredPaths.add(ignoredPath);
305 }
306 return true;
307 };
308
309 for (const relativePath of relativePaths) {
310 const relativePathBytes = Buffer.byteLength(relativePath) + 1;
311 if (
312 chunk.length > 0 &&
313 chunkBytes + relativePathBytes > GIT_CHECK_IGNORE_MAX_STDIN_BYTES &&
314 !(await flushChunk())
315 ) {
316 return relativePaths;
317 }
318
319 chunk.push(relativePath);
320 chunkBytes += relativePathBytes;
321
322 if (chunkBytes >= GIT_CHECK_IGNORE_MAX_STDIN_BYTES && !(await flushChunk())) {
323 return relativePaths;
324 }
325 }
326
327 if (!(await flushChunk())) {
328 return relativePaths;
329 }
330
331 if (ignoredPaths.size === 0) {
332 return relativePaths;
333 }
334
335 return relativePaths.filter((relativePath) => !ignoredPaths.has(relativePath));
336}
337
338async function buildWorkspaceIndexFromGit(cwd: string): Promise<WorkspaceIndex | null> {
339 if (!(await isInsideGitWorkTree(cwd))) {
340 return null;
341 }
342
343 const listedFiles = await runProcess(
344 "git",
345 ["ls-files", "--cached", "--others", "--exclude-standard", "-z"],
346 {
347 cwd,
348 allowNonZeroExit: true,
349 timeoutMs: 20_000,
350 maxBufferBytes: 16 * 1024 * 1024,
351 outputMode: "truncate",
352 },
353 ).catch(() => null);
354 if (!listedFiles || listedFiles.code !== 0) {
355 return null;
356 }
357
358 const listedPaths = splitNullSeparatedPaths(
359 listedFiles.stdout,
360 Boolean(listedFiles.stdoutTruncated),
361 )
362 .map((entry) => toPosixPath(entry))
363 .filter((entry) => entry.length > 0 && !isPathInIgnoredDirectory(entry));
364 const filePaths = await filterGitIgnoredPaths(cwd, listedPaths);
365
366 const directorySet = new Set<string>();
367 for (const filePath of filePaths) {
368 for (const directoryPath of directoryAncestorsOf(filePath)) {
369 if (!isPathInIgnoredDirectory(directoryPath)) {
370 directorySet.add(directoryPath);
371 }
372 }
373 }
374
375 const directoryEntries = [...directorySet]
376 .toSorted((left, right) => left.localeCompare(right))
377 .map(
378 (directoryPath): ProjectEntry => ({
379 path: directoryPath,
380 kind: "directory",
381 parentPath: parentPathOf(directoryPath),
382 }),
383 )
384 .map(toSearchableWorkspaceEntry);
385 const fileEntries = [...new Set(filePaths)]
386 .toSorted((left, right) => left.localeCompare(right))
387 .map(
388 (filePath): ProjectEntry => ({
389 path: filePath,
390 kind: "file",
391 parentPath: parentPathOf(filePath),
392 }),
393 )
394 .map(toSearchableWorkspaceEntry);
395
396 const entries = [...directoryEntries, ...fileEntries];
397 return {
398 scannedAt: Date.now(),
399 entries: entries.slice(0, WORKSPACE_INDEX_MAX_ENTRIES),
400 truncated: Boolean(listedFiles.stdoutTruncated) || entries.length > WORKSPACE_INDEX_MAX_ENTRIES,
401 };
402}
403
404async function buildWorkspaceIndex(cwd: string): Promise<WorkspaceIndex> {
405 const gitIndexed = await buildWorkspaceIndexFromGit(cwd);
406 if (gitIndexed) {
407 return gitIndexed;
408 }
409 const shouldFilterWithGitIgnore = await isInsideGitWorkTree(cwd);
410
411 let pendingDirectories: string[] = [""];
412 const entries: SearchableWorkspaceEntry[] = [];
413 let truncated = false;
414
415 while (pendingDirectories.length > 0 && !truncated) {
416 const currentDirectories = pendingDirectories;
417 pendingDirectories = [];
418 const directoryEntries = await mapWithConcurrency(
419 currentDirectories,
420 WORKSPACE_SCAN_READDIR_CONCURRENCY,
421 async (relativeDir) => {
422 const absoluteDir = relativeDir ? path.join(cwd, relativeDir) : cwd;
423 try {
424 const dirents = await fs.readdir(absoluteDir, { withFileTypes: true });
425 return { relativeDir, dirents };
426 } catch (error) {
427 if (!relativeDir) {
428 throw new Error(
429 `Unable to scan workspace entries at '${cwd}': ${error instanceof Error ? error.message : "unknown error"}`,
430 { cause: error },
431 );
432 }
433 return { relativeDir, dirents: null };
434 }
435 },
436 );
437
438 const candidateEntriesByDirectory = directoryEntries.map((directoryEntry) => {
439 const { relativeDir, dirents } = directoryEntry;
440 if (!dirents) return [] as Array<{ dirent: Dirent; relativePath: string }>;
441
442 dirents.sort((left, right) => left.name.localeCompare(right.name));
443 const candidates: Array<{ dirent: Dirent; relativePath: string }> = [];
444 for (const dirent of dirents) {
445 if (!dirent.name || dirent.name === "." || dirent.name === "..") {
446 continue;
447 }
448 if (dirent.isDirectory() && IGNORED_DIRECTORY_NAMES.has(dirent.name)) {
449 continue;
450 }
451 if (!dirent.isDirectory() && !dirent.isFile()) {
452 continue;
453 }
454
455 const relativePath = toPosixPath(
456 relativeDir ? path.join(relativeDir, dirent.name) : dirent.name,
457 );
458 if (isPathInIgnoredDirectory(relativePath)) {
459 continue;
460 }
461 candidates.push({ dirent, relativePath });
462 }
463 return candidates;
464 });
465
466 const candidatePaths = candidateEntriesByDirectory.flatMap((candidateEntries) =>
467 candidateEntries.map((entry) => entry.relativePath),
468 );
469 const allowedPathSet = shouldFilterWithGitIgnore
470 ? new Set(await filterGitIgnoredPaths(cwd, candidatePaths))
471 : null;
472
473 for (const candidateEntries of candidateEntriesByDirectory) {
474 for (const candidate of candidateEntries) {
475 if (allowedPathSet && !allowedPathSet.has(candidate.relativePath)) {
476 continue;
477 }
478
479 const entry = toSearchableWorkspaceEntry({
480 path: candidate.relativePath,
481 kind: candidate.dirent.isDirectory() ? "directory" : "file",
482 parentPath: parentPathOf(candidate.relativePath),
483 });
484 entries.push(entry);
485
486 if (candidate.dirent.isDirectory()) {
487 pendingDirectories.push(candidate.relativePath);
488 }
489
490 if (entries.length >= WORKSPACE_INDEX_MAX_ENTRIES) {
491 truncated = true;
492 break;
493 }
494 }
495
496 if (truncated) {
497 break;
498 }
499 }
500 }
501
502 return {
503 scannedAt: Date.now(),
504 entries,
505 truncated,
506 };
507}
508
509async function getWorkspaceIndex(cwd: string): Promise<WorkspaceIndex> {
510 const cached = workspaceIndexCache.get(cwd);
511 if (cached && Date.now() - cached.scannedAt < WORKSPACE_CACHE_TTL_MS) {
512 return cached;
513 }
514
515 const inFlight = inFlightWorkspaceIndexBuilds.get(cwd);
516 if (inFlight) {
517 return inFlight;
518 }
519
520 const nextPromise = buildWorkspaceIndex(cwd)
521 .then((next) => {
522 workspaceIndexCache.set(cwd, next);
523 while (workspaceIndexCache.size > WORKSPACE_CACHE_MAX_KEYS) {
524 const oldestKey = workspaceIndexCache.keys().next().value;
525 if (!oldestKey) break;
526 workspaceIndexCache.delete(oldestKey);
527 }
528 return next;
529 })
530 .finally(() => {
531 inFlightWorkspaceIndexBuilds.delete(cwd);
532 });
533 inFlightWorkspaceIndexBuilds.set(cwd, nextPromise);
534 return nextPromise;
535}
536
537export function clearWorkspaceIndexCache(cwd: string): void {
538 workspaceIndexCache.delete(cwd);
539 inFlightWorkspaceIndexBuilds.delete(cwd);
540}
541
542export async function searchWorkspaceEntries(
543 input: ProjectSearchEntriesInput,
544): Promise<ProjectSearchEntriesResult> {
545 const index = await getWorkspaceIndex(input.cwd);
546 const normalizedQuery = normalizeQuery(input.query);
547 const limit = Math.max(0, Math.floor(input.limit));
548 const rankedEntries: RankedWorkspaceEntry[] = [];
549 let matchedEntryCount = 0;
550
551 for (const entry of index.entries) {
552 const score = scoreEntry(entry, normalizedQuery);
553 if (score === null) {
554 continue;
555 }
556
557 matchedEntryCount += 1;
558 insertRankedEntry(rankedEntries, { entry, score }, limit);
559 }
560
561 return {
562 entries: rankedEntries.map((candidate) => candidate.entry),
563 truncated: index.truncated || matchedEntryCount > limit,
564 };
565}
apps/server/src/wsServer.test.ts+122-22
...@@ -4,7 +4,17 @@ import os from "node:os";...@@ -4,7 +4,17 @@ import os from "node:os";
4import path from "node:path";4import path from "node:path";
55
6import * as NodeServices from "@effect/platform-node/NodeServices";6import * as NodeServices from "@effect/platform-node/NodeServices";
7import { Effect, Exit, Fiber, Layer, PlatformError, PubSub, Scope, Stream } from "effect";7import {
8 Effect,
9 Exit,
10 Fiber,
11 Layer,
12 ManagedRuntime,
13 PlatformError,
14 PubSub,
15 Scope,
16 Stream,
17} from "effect";
8import { describe, expect, it, afterEach, vi } from "vitest";18import { describe, expect, it, afterEach, vi } from "vitest";
9import { createServer } from "./wsServer";19import { createServer } from "./wsServer";
10import WebSocket from "ws";20import WebSocket from "ws";
...@@ -475,6 +485,7 @@ function deriveServerPathsSync(baseDir: string, devUrl: URL | undefined) {...@@ -475,6 +485,7 @@ function deriveServerPathsSync(baseDir: string, devUrl: URL | undefined) {
475describe("WebSocket Server", () => {485describe("WebSocket Server", () => {
476 let server: Http.Server | null = null;486 let server: Http.Server | null = null;
477 let serverScope: Scope.Closeable | null = null;487 let serverScope: Scope.Closeable | null = null;
488 let disposeServerRuntime: (() => Promise<void>) | null = null;
478 const connections: WebSocket[] = [];489 const connections: WebSocket[] = [];
479 const tempDirs: string[] = [];490 const tempDirs: string[] = [];
480491
...@@ -521,6 +532,12 @@ describe("WebSocket Server", () => {...@@ -521,6 +532,12 @@ describe("WebSocket Server", () => {
521 options.providerRegistry ?? defaultProviderRegistryService,532 options.providerRegistry ?? defaultProviderRegistryService,
522 );533 );
523 const openLayer = Layer.succeed(Open, options.open ?? defaultOpenService);534 const openLayer = Layer.succeed(Open, options.open ?? defaultOpenService);
535 const nodeServicesLayer = NodeServices.layer;
536 const serverSettingsLayer = ServerSettingsService.layerTest(options.serverSettings);
537 const serverSettingsRuntimeLayer = serverSettingsLayer.pipe(
538 Layer.provideMerge(nodeServicesLayer),
539 );
540 const analyticsLayer = AnalyticsService.layerTest;
524 const serverConfigLayer = Layer.succeed(ServerConfig, {541 const serverConfigLayer = Layer.succeed(ServerConfig, {
525 mode: "web",542 mode: "web",
526 port: 0,543 port: 0,
...@@ -536,6 +553,11 @@ describe("WebSocket Server", () => {...@@ -536,6 +553,11 @@ describe("WebSocket Server", () => {
536 logWebSocketEvents: options.logWebSocketEvents ?? Boolean(options.devUrl),553 logWebSocketEvents: options.logWebSocketEvents ?? Boolean(options.devUrl),
537 } satisfies ServerConfigShape);554 } satisfies ServerConfigShape);
538 const infrastructureLayer = providerLayer.pipe(Layer.provideMerge(persistenceLayer));555 const infrastructureLayer = providerLayer.pipe(Layer.provideMerge(persistenceLayer));
556 const providerRuntimeLayer = infrastructureLayer.pipe(
557 Layer.provideMerge(serverConfigLayer),
558 Layer.provideMerge(serverSettingsRuntimeLayer),
559 Layer.provideMerge(analyticsLayer),
560 );
539 const runtimeOverrides = Layer.mergeAll(561 const runtimeOverrides = Layer.mergeAll(
540 options.gitManager ? Layer.succeed(GitManager, options.gitManager) : Layer.empty,562 options.gitManager ? Layer.succeed(GitManager, options.gitManager) : Layer.empty,
541 options.gitCore563 options.gitCore
...@@ -548,41 +570,49 @@ describe("WebSocket Server", () => {...@@ -548,41 +570,49 @@ describe("WebSocket Server", () => {
548570
549 const runtimeLayer = Layer.merge(571 const runtimeLayer = Layer.merge(
550 Layer.merge(572 Layer.merge(
551 makeServerRuntimeServicesLayer().pipe(Layer.provide(infrastructureLayer)),573 makeServerRuntimeServicesLayer().pipe(
552 infrastructureLayer,574 Layer.provideMerge(providerRuntimeLayer),
575 Layer.provideMerge(serverConfigLayer),
576 Layer.provideMerge(serverSettingsRuntimeLayer),
577 Layer.provideMerge(analyticsLayer),
578 Layer.provideMerge(nodeServicesLayer),
579 ),
580 Layer.mergeAll(providerRuntimeLayer, serverSettingsRuntimeLayer, analyticsLayer),
553 ),581 ),
554 runtimeOverrides,582 runtimeOverrides,
555 );583 );
556 const dependenciesLayer = Layer.empty.pipe(584 const dependenciesLayer = Layer.mergeAll(
557 Layer.provideMerge(runtimeLayer),585 runtimeLayer,
558 Layer.provideMerge(providerRegistryLayer),586 providerRegistryLayer,
559 Layer.provideMerge(openLayer),587 openLayer,
560 Layer.provideMerge(ServerSettingsService.layerTest(options.serverSettings)),588 serverConfigLayer,
561 Layer.provideMerge(serverConfigLayer),589 nodeServicesLayer,
562 Layer.provideMerge(AnalyticsService.layerTest),
563 Layer.provideMerge(NodeServices.layer),
564 );
565 const runtimeServices = await Effect.runPromise(
566 Layer.build(dependenciesLayer).pipe(Scope.provide(scope)),
567 );590 );
568591 const runtime = ManagedRuntime.make(dependenciesLayer);
569 try {592 try {
570 const runtime = await Effect.runPromise(593 const httpServer = await runtime.runPromise(createServer().pipe(Scope.provide(scope)));
571 createServer().pipe(Effect.provide(runtimeServices), Scope.provide(scope)),594 disposeServerRuntime = () => runtime.dispose();
572 );
573 serverScope = scope;595 serverScope = scope;
574 return runtime;596 return httpServer;
575 } catch (error) {597 } catch (error) {
598 await runtime.dispose();
576 await Effect.runPromise(Scope.close(scope, Exit.void));599 await Effect.runPromise(Scope.close(scope, Exit.void));
577 throw error;600 throw error;
578 }601 }
579 }602 }
580603
581 async function closeTestServer() {604 async function closeTestServer() {
582 if (!serverScope) return;605 if (!serverScope && !disposeServerRuntime) return;
583 const scope = serverScope;606 const scope = serverScope;
607 const disposeRuntime = disposeServerRuntime;
584 serverScope = null;608 serverScope = null;
585 await Effect.runPromise(Scope.close(scope, Exit.void));609 disposeServerRuntime = null;
610 if (scope) {
611 await Effect.runPromise(Scope.close(scope, Exit.void));
612 }
613 if (disposeRuntime) {
614 await disposeRuntime();
615 }
586 }616 }
587617
588 afterEach(async () => {618 afterEach(async () => {
...@@ -1255,6 +1285,32 @@ describe("WebSocket Server", () => {...@@ -1255,6 +1285,32 @@ describe("WebSocket Server", () => {
1255 expect(response.error?.message).toContain("exceeds current turn count");1285 expect(response.error?.message).toContain("exceeds current turn count");
1256 });1286 });
12571287
1288 it("rejects project.create when the workspace root does not exist", async () => {
1289 server = await createTestServer({ cwd: "/test" });
1290 const addr = server.address();
1291 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
1292
1293 const [ws] = await connectAndAwaitWelcome(port);
1294 connections.push(ws);
1295
1296 const missingWorkspaceRoot = path.join(makeTempDir("t3code-ws-project-missing-"), "missing");
1297 const response = await sendRequest(ws, ORCHESTRATION_WS_METHODS.dispatchCommand, {
1298 type: "project.create",
1299 commandId: "cmd-ws-project-create-missing",
1300 projectId: "project-missing",
1301 title: "Missing Project",
1302 workspaceRoot: missingWorkspaceRoot,
1303 defaultModelSelection: {
1304 provider: "codex",
1305 model: "gpt-5-codex",
1306 },
1307 createdAt: new Date().toISOString(),
1308 });
1309
1310 expect(response.result).toBeUndefined();
1311 expect(response.error?.message).toContain("Workspace root does not exist:");
1312 });
1313
1258 it("keeps orchestration domain push behavior for provider runtime events", async () => {1314 it("keeps orchestration domain push behavior for provider runtime events", async () => {
1259 const runtimeEventPubSub = Effect.runSync(PubSub.unbounded<ProviderRuntimeEvent>());1315 const runtimeEventPubSub = Effect.runSync(PubSub.unbounded<ProviderRuntimeEvent>());
1260 const emitRuntimeEvent = (event: ProviderRuntimeEvent) => {1316 const emitRuntimeEvent = (event: ProviderRuntimeEvent) => {
...@@ -1665,6 +1721,50 @@ describe("WebSocket Server", () => {...@@ -1665,6 +1721,50 @@ describe("WebSocket Server", () => {
1665 );1721 );
1666 });1722 });
16671723
1724 it("invalidates workspace entry search cache after projects.writeFile", async () => {
1725 const workspace = makeTempDir("t3code-ws-write-file-invalidate-");
1726 fs.mkdirSync(path.join(workspace, "src"), { recursive: true });
1727 fs.writeFileSync(path.join(workspace, "src", "existing.ts"), "export {};\n", "utf8");
1728
1729 server = await createTestServer({ cwd: "/test" });
1730 const addr = server.address();
1731 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
1732
1733 const [ws] = await connectAndAwaitWelcome(port);
1734 connections.push(ws);
1735
1736 const beforeWrite = await sendRequest(ws, WS_METHODS.projectsSearchEntries, {
1737 cwd: workspace,
1738 query: "rpc",
1739 limit: 10,
1740 });
1741 expect(beforeWrite.error).toBeUndefined();
1742 expect(beforeWrite.result).toEqual({
1743 entries: [],
1744 truncated: false,
1745 });
1746
1747 const writeResponse = await sendRequest(ws, WS_METHODS.projectsWriteFile, {
1748 cwd: workspace,
1749 relativePath: "plans/effect-rpc.md",
1750 contents: "# Plan\n",
1751 });
1752 expect(writeResponse.error).toBeUndefined();
1753
1754 const afterWrite = await sendRequest(ws, WS_METHODS.projectsSearchEntries, {
1755 cwd: workspace,
1756 query: "rpc",
1757 limit: 10,
1758 });
1759 expect(afterWrite.error).toBeUndefined();
1760 expect(afterWrite.result).toEqual({
1761 entries: expect.arrayContaining([
1762 expect.objectContaining({ path: "plans/effect-rpc.md", kind: "file" }),
1763 ]),
1764 truncated: false,
1765 });
1766 });
1767
1668 it("rejects projects.writeFile paths outside the workspace root", async () => {1768 it("rejects projects.writeFile paths outside the workspace root", async () => {
1669 const workspace = makeTempDir("t3code-ws-write-file-reject-");1769 const workspace = makeTempDir("t3code-ws-write-file-reject-");
16701770
...@@ -1683,7 +1783,7 @@ describe("WebSocket Server", () => {...@@ -1683,7 +1783,7 @@ describe("WebSocket Server", () => {
16831783
1684 expect(response.result).toBeUndefined();1784 expect(response.result).toBeUndefined();
1685 expect(response.error?.message).toContain(1785 expect(response.error?.message).toContain(
1686 "Workspace file path must stay within the project root.",1786 "Workspace file path must be relative to the project root: ../escape.md",
1687 );1787 );
1688 expect(fs.existsSync(path.join(workspace, "..", "escape.md"))).toBe(false);1788 expect(fs.existsSync(path.join(workspace, "..", "escape.md"))).toBe(false);
1689 });1789 });
apps/server/src/wsServer.ts+28-90
...@@ -50,7 +50,6 @@ import { GitManager } from "./git/Services/GitManager.ts";...@@ -50,7 +50,6 @@ import { GitManager } from "./git/Services/GitManager.ts";
50import { TerminalManager } from "./terminal/Services/Manager.ts";50import { TerminalManager } from "./terminal/Services/Manager.ts";
51import { Keybindings } from "./keybindings";51import { Keybindings } from "./keybindings";
52import { ServerSettingsService } from "./serverSettings";52import { ServerSettingsService } from "./serverSettings";
53import { searchWorkspaceEntries } from "./workspaceEntries";
54import { OrchestrationEngineService } from "./orchestration/Services/OrchestrationEngine";53import { OrchestrationEngineService } from "./orchestration/Services/OrchestrationEngine";
55import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery";54import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery";
56import { OrchestrationReactor } from "./orchestration/Services/OrchestrationReactor";55import { OrchestrationReactor } from "./orchestration/Services/OrchestrationReactor";
...@@ -75,10 +74,13 @@ import {...@@ -75,10 +74,13 @@ import {
75} from "./attachmentStore.ts";74} from "./attachmentStore.ts";
76import { parseBase64DataUrl } from "./imageMime.ts";75import { parseBase64DataUrl } from "./imageMime.ts";
77import { AnalyticsService } from "./telemetry/Services/AnalyticsService.ts";76import { AnalyticsService } from "./telemetry/Services/AnalyticsService.ts";
78import { expandHomePath } from "./os-jank.ts";
79import { makeServerPushBus } from "./wsServer/pushBus.ts";77import { makeServerPushBus } from "./wsServer/pushBus.ts";
80import { makeServerReadiness } from "./wsServer/readiness.ts";78import { makeServerReadiness } from "./wsServer/readiness.ts";
81import { decodeJsonResult, formatSchemaError } from "@t3tools/shared/schemaJson";79import { decodeJsonResult, formatSchemaError } from "@t3tools/shared/schemaJson";
80import { ProjectFaviconResolver } from "./project/Services/ProjectFaviconResolver.ts";
81import { WorkspaceEntries } from "./workspace/Services/WorkspaceEntries.ts";
82import { WorkspaceFileSystem } from "./workspace/Services/WorkspaceFileSystem.ts";
83import { WorkspacePaths } from "./workspace/Services/WorkspacePaths.ts";
8284
83/**85/**
84 * ServerShape - Service API for server lifecycle control.86 * ServerShape - Service API for server lifecycle control.
...@@ -154,48 +156,6 @@ function websocketRawToString(raw: unknown): string | null {...@@ -154,48 +156,6 @@ function websocketRawToString(raw: unknown): string | null {
154 return null;156 return null;
155}157}
156158
157function toPosixRelativePath(input: string): string {
158 return input.replaceAll("\\", "/");
159}
160
161function resolveWorkspaceWritePath(params: {
162 workspaceRoot: string;
163 relativePath: string;
164 path: Path.Path;
165}): Effect.Effect<{ absolutePath: string; relativePath: string }, RouteRequestError> {
166 const normalizedInputPath = params.relativePath.trim();
167 if (params.path.isAbsolute(normalizedInputPath)) {
168 return Effect.fail(
169 new RouteRequestError({
170 message: "Workspace file path must be relative to the project root.",
171 }),
172 );
173 }
174
175 const absolutePath = params.path.resolve(params.workspaceRoot, normalizedInputPath);
176 const relativeToRoot = toPosixRelativePath(
177 params.path.relative(params.workspaceRoot, absolutePath),
178 );
179 if (
180 relativeToRoot.length === 0 ||
181 relativeToRoot === "." ||
182 relativeToRoot.startsWith("../") ||
183 relativeToRoot === ".." ||
184 params.path.isAbsolute(relativeToRoot)
185 ) {
186 return Effect.fail(
187 new RouteRequestError({
188 message: "Workspace file path must stay within the project root.",
189 }),
190 );
191 }
192
193 return Effect.succeed({
194 absolutePath,
195 relativePath: relativeToRoot,
196 });
197}
198
199function stripRequestTag<T extends { _tag: string }>(body: T) {159function stripRequestTag<T extends { _tag: string }>(body: T) {
200 return Struct.omit(body, ["_tag"]);160 return Struct.omit(body, ["_tag"]);
201}161}
...@@ -218,6 +178,10 @@ export type ServerRuntimeServices =...@@ -218,6 +178,10 @@ export type ServerRuntimeServices =
218 | TerminalManager178 | TerminalManager
219 | Keybindings179 | Keybindings
220 | ServerSettingsService180 | ServerSettingsService
181 | ProjectFaviconResolver
182 | WorkspaceEntries
183 | WorkspaceFileSystem
184 | WorkspacePaths
221 | Open185 | Open
222 | AnalyticsService;186 | AnalyticsService;
223187
...@@ -263,6 +227,9 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<...@@ -263,6 +227,9 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<
263 const serverSettingsManager = yield* ServerSettingsService;227 const serverSettingsManager = yield* ServerSettingsService;
264 const providerRegistry = yield* ProviderRegistry;228 const providerRegistry = yield* ProviderRegistry;
265 const git = yield* GitCore;229 const git = yield* GitCore;
230 const workspaceEntries = yield* WorkspaceEntries;
231 const workspaceFileSystem = yield* WorkspaceFileSystem;
232 const workspacePaths = yield* WorkspacePaths;
266 const fileSystem = yield* FileSystem.FileSystem;233 const fileSystem = yield* FileSystem.FileSystem;
267 const path = yield* Path.Path;234 const path = yield* Path.Path;
268235
...@@ -312,35 +279,21 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<...@@ -312,35 +279,21 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<
312 const normalizeDispatchCommand = Effect.fnUntraced(function* (input: {279 const normalizeDispatchCommand = Effect.fnUntraced(function* (input: {
313 readonly command: ClientOrchestrationCommand;280 readonly command: ClientOrchestrationCommand;
314 }) {281 }) {
315 const normalizeProjectWorkspaceRoot = Effect.fnUntraced(function* (workspaceRoot: string) {
316 const normalizedWorkspaceRoot = path.resolve(yield* expandHomePath(workspaceRoot.trim()));
317 const workspaceStat = yield* fileSystem
318 .stat(normalizedWorkspaceRoot)
319 .pipe(Effect.catch(() => Effect.succeed(null)));
320 if (!workspaceStat) {
321 return yield* new RouteRequestError({
322 message: `Project directory does not exist: ${normalizedWorkspaceRoot}`,
323 });
324 }
325 if (workspaceStat.type !== "Directory") {
326 return yield* new RouteRequestError({
327 message: `Project path is not a directory: ${normalizedWorkspaceRoot}`,
328 });
329 }
330 return normalizedWorkspaceRoot;
331 });
332
333 if (input.command.type === "project.create") {282 if (input.command.type === "project.create") {
334 return {283 return {
335 ...input.command,284 ...input.command,
336 workspaceRoot: yield* normalizeProjectWorkspaceRoot(input.command.workspaceRoot),285 workspaceRoot: yield* workspacePaths
286 .normalizeWorkspaceRoot(input.command.workspaceRoot)
287 .pipe(Effect.mapError((cause) => new RouteRequestError({ message: cause.message }))),
337 } satisfies OrchestrationCommand;288 } satisfies OrchestrationCommand;
338 }289 }
339290
340 if (input.command.type === "project.meta.update" && input.command.workspaceRoot !== undefined) {291 if (input.command.type === "project.meta.update" && input.command.workspaceRoot !== undefined) {
341 return {292 return {
342 ...input.command,293 ...input.command,
343 workspaceRoot: yield* normalizeProjectWorkspaceRoot(input.command.workspaceRoot),294 workspaceRoot: yield* workspacePaths
295 .normalizeWorkspaceRoot(input.command.workspaceRoot)
296 .pipe(Effect.mapError((cause) => new RouteRequestError({ message: cause.message }))),
344 } satisfies OrchestrationCommand;297 } satisfies OrchestrationCommand;
345 }298 }
346299
...@@ -437,7 +390,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<...@@ -437,7 +390,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<
437 void runPromise(390 void runPromise(
438 Effect.gen(function* () {391 Effect.gen(function* () {
439 const url = new URL(req.url ?? "/", `http://localhost:${port}`);392 const url = new URL(req.url ?? "/", `http://localhost:${port}`);
440 if (tryHandleProjectFaviconRequest(url, res)) {393 if (yield* tryHandleProjectFaviconRequest(url, res)) {
441 return;394 return;
442 }395 }
443396
...@@ -768,41 +721,26 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<...@@ -768,41 +721,26 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<
768721
769 case WS_METHODS.projectsSearchEntries: {722 case WS_METHODS.projectsSearchEntries: {
770 const body = stripRequestTag(request.body);723 const body = stripRequestTag(request.body);
771 return yield* Effect.tryPromise({724 return yield* workspaceEntries.search(body).pipe(
772 try: () => searchWorkspaceEntries(body),725 Effect.mapError(
773 catch: (cause) =>726 (cause) =>
774 new RouteRequestError({727 new RouteRequestError({
775 message: `Failed to search workspace entries: ${String(cause)}`,728 message: `Failed to search workspace entries: ${cause.detail}`,
776 }),729 }),
777 });730 ),
731 );
778 }732 }
779733
780 case WS_METHODS.projectsWriteFile: {734 case WS_METHODS.projectsWriteFile: {
781 const body = stripRequestTag(request.body);735 const body = stripRequestTag(request.body);
782 const target = yield* resolveWorkspaceWritePath({736 return yield* workspaceFileSystem.writeFile(body).pipe(
783 workspaceRoot: body.cwd,
784 relativePath: body.relativePath,
785 path,
786 });
787 yield* fileSystem
788 .makeDirectory(path.dirname(target.absolutePath), { recursive: true })
789 .pipe(
790 Effect.mapError(
791 (cause) =>
792 new RouteRequestError({
793 message: `Failed to prepare workspace path: ${String(cause)}`,
794 }),
795 ),
796 );
797 yield* fileSystem.writeFileString(target.absolutePath, body.contents).pipe(
798 Effect.mapError(737 Effect.mapError(
799 (cause) =>738 (cause) =>
800 new RouteRequestError({739 new RouteRequestError({
801 message: `Failed to write workspace file: ${String(cause)}`,740 message: `Failed to write workspace file: ${cause.message}`,
802 }),741 }),
803 ),742 ),
804 );743 );
805 return { relativePath: target.relativePath };
806 }744 }
807745
808 case WS_METHODS.shellOpenInEditor: {746 case WS_METHODS.shellOpenInEditor: {