| author | |
| committer | |
| log | 2d03847ccd53ffcd5ba74301ce85aef2afe58ad5 |
| tree | 980cd38ae17de7b01d05f370b736997330cad6ce |
| parent | d303ac39cbd97a94489b460f62eac153ad1857ad |
| signature | Signed by PGP key B5690EEEBB952194 |
26 files changed, 2077 insertions(+), 1140 deletions(-)
apps/server/integration/OrchestrationEngineHarness.integration.ts+7| ... | ... | @@ -68,6 +68,7 @@ import { |
| 68 | 68 | type TestProviderAdapterHarness, |
| 69 | 69 | } from "./TestProviderAdapter.integration.ts"; |
| 70 | 70 | import { deriveServerPaths, ServerConfig } from "../src/config.ts"; |
| 71 | import { WorkspaceEntriesLive } from "../src/workspace/Layers/WorkspaceEntries.ts"; | |
| 71 | 72 | |
| 72 | 73 | function runGit(cwd: string, args: ReadonlyArray<string>) { |
| 73 | 74 | return execFileSync("git", args, { |
| ... | ... | @@ -317,6 +318,12 @@ export const makeOrchestrationIntegrationHarness = ( |
| 317 | 318 | ); |
| 318 | 319 | const checkpointReactorLayer = CheckpointReactorLive.pipe( |
| 319 | 320 | Layer.provideMerge(runtimeServicesLayer), |
| 321 | Layer.provideMerge( | |
| 322 | WorkspaceEntriesLive.pipe( | |
| 323 | Layer.provideMerge(gitCoreLayer), | |
| 324 | Layer.provide(NodeServices.layer), | |
| 325 | ), | |
| 326 | ), | |
| 320 | 327 | ); |
| 321 | 328 | const orchestrationReactorLayer = OrchestrationReactorLive.pipe( |
| 322 | 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 | 138 | .concat("\n"); |
| 139 | 139 | } |
| 140 | 140 | |
| 141 | function splitNullSeparatedPaths(input: string): string[] { | |
| 142 | return input | |
| 143 | .split("\0") | |
| 144 | .map((value) => value.trim()) | |
| 145 | .filter((value) => value.length > 0); | |
| 146 | } | |
| 147 | ||
| 141 | 148 | // ── Tests ── |
| 142 | 149 | |
| 143 | 150 | it.layer(TestLayer)("git integration", (it) => { |
| ... | ... | @@ -181,6 +188,55 @@ it.layer(TestLayer)("git integration", (it) => { |
| 181 | 188 | ); |
| 182 | 189 | }); |
| 183 | 190 | |
| 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 | 240 | // ── listGitBranches ── |
| 185 | 241 | |
| 186 | 242 | describe("listGitBranches", () => { |
| ... | ... | @@ -457,12 +513,18 @@ it.layer(TestLayer)("git integration", (it) => { |
| 457 | 513 | yield* (yield* GitCore).checkoutBranch({ cwd: source, branch: featureBranch }); |
| 458 | 514 | const core = yield* GitCore; |
| 459 | 515 | yield* Effect.promise(() => |
| 460 | vi.waitFor(async () => { | |
| 461 | const details = await runPromise(core.statusDetails(source)); | |
| 462 | expect(details.branch).toBe(featureBranch); | |
| 463 | expect(details.aheadCount).toBe(0); | |
| 464 | expect(details.behindCount).toBe(1); | |
| 465 | }), | |
| 516 | vi.waitFor( | |
| 517 | async () => { | |
| 518 | const details = await runPromise(core.statusDetails(source)); | |
| 519 | expect(details.branch).toBe(featureBranch); | |
| 520 | expect(details.aheadCount).toBe(0); | |
| 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 | 603 | const core = yield* makeIsolatedGitCore((input) => { |
| 542 | 604 | if (input.args[0] === "fetch") { |
| 543 | 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 | 614 | return realGitCore.execute(input); |
| 547 | 615 | }); |
| ... | ... | @@ -594,7 +662,13 @@ it.layer(TestLayer)("git integration", (it) => { |
| 594 | 662 | if (input.args[0] === "fetch") { |
| 595 | 663 | fetchStarted = true; |
| 596 | 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 | 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 | 37 | const RANGE_COMMIT_SUMMARY_MAX_OUTPUT_BYTES = 19_000; |
| 38 | 38 | const RANGE_DIFF_SUMMARY_MAX_OUTPUT_BYTES = 19_000; |
| 39 | 39 | const RANGE_DIFF_PATCH_MAX_OUTPUT_BYTES = 59_000; |
| 40 | const WORKSPACE_FILES_MAX_OUTPUT_BYTES = 16 * 1024 * 1024; | |
| 41 | const GIT_CHECK_IGNORE_MAX_STDIN_BYTES = 256 * 1024; | |
| 40 | 42 | const STATUS_UPSTREAM_REFRESH_INTERVAL = Duration.seconds(15); |
| 41 | 43 | const STATUS_UPSTREAM_REFRESH_TIMEOUT = Duration.seconds(5); |
| 42 | 44 | const STATUS_UPSTREAM_REFRESH_CACHE_CAPACITY = 2_048; |
| ... | ... | @@ -55,6 +57,7 @@ class StatusUpstreamRefreshCacheKey extends Data.Class<{ |
| 55 | 57 | }> {} |
| 56 | 58 | |
| 57 | 59 | interface ExecuteGitOptions { |
| 60 | stdin?: string | undefined; | |
| 58 | 61 | timeoutMs?: number | undefined; |
| 59 | 62 | allowNonZeroExit?: boolean | undefined; |
| 60 | 63 | fallbackErrorMessage?: string | undefined; |
| ... | ... | @@ -96,6 +99,47 @@ function parseNumstatEntries( |
| 96 | 99 | return entries; |
| 97 | 100 | } |
| 98 | 101 | |
| 102 | function 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 | ||
| 113 | function 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 | ||
| 99 | 143 | function parsePorcelainPath(line: string): string | null { |
| 100 | 144 | if (line.startsWith("? ") || line.startsWith("! ")) { |
| 101 | 145 | const simple = line.slice(2).trim(); |
| ... | ... | @@ -445,7 +489,7 @@ const collectOutput = Effect.fn("collectOutput")(function* <E>( |
| 445 | 489 | maxOutputBytes: number, |
| 446 | 490 | truncateOutputAtMaxBytes: boolean, |
| 447 | 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 | 493 | const decoder = new TextDecoder(); |
| 450 | 494 | let bytes = 0; |
| 451 | 495 | let text = ""; |
| ... | ... | @@ -507,7 +551,10 @@ const collectOutput = Effect.fn("collectOutput")(function* <E>( |
| 507 | 551 | text += remainder; |
| 508 | 552 | lineBuffer += remainder; |
| 509 | 553 | yield* emitCompleteLines(true); |
| 510 | return truncated ? `${text}${OUTPUT_TRUNCATED_MARKER}` : text; | |
| 554 | return { | |
| 555 | text, | |
| 556 | truncated, | |
| 557 | }; | |
| 511 | 558 | }); |
| 512 | 559 | |
| 513 | 560 | export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { |
| ... | ... | @@ -571,13 +618,18 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { |
| 571 | 618 | Effect.map((value) => Number(value)), |
| 572 | 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 | 627 | { concurrency: "unbounded" }, |
| 576 | ); | |
| 628 | ).pipe(Effect.map(([stdout, stderr, exitCode]) => [stdout, stderr, exitCode] as const)); | |
| 577 | 629 | yield* trace2Monitor.flush; |
| 578 | 630 | |
| 579 | 631 | if (!input.allowNonZeroExit && exitCode !== 0) { |
| 580 | const trimmedStderr = stderr.trim(); | |
| 632 | const trimmedStderr = stderr.text.trim(); | |
| 581 | 633 | return yield* new GitCommandError({ |
| 582 | 634 | operation: commandInput.operation, |
| 583 | 635 | command: quoteGitCommand(commandInput.args), |
| ... | ... | @@ -589,7 +641,13 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { |
| 589 | 641 | }); |
| 590 | 642 | } |
| 591 | 643 | |
| 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 | }); |
| 594 | 652 | |
| 595 | 653 | return yield* runGitCommand().pipe( |
| ... | ... | @@ -618,11 +676,12 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { |
| 618 | 676 | cwd: string, |
| 619 | 677 | args: readonly string[], |
| 620 | 678 | options: ExecuteGitOptions = {}, |
| 621 | ): Effect.Effect<{ code: number; stdout: string; stderr: string }, GitCommandError> => | |
| 679 | ): Effect.Effect<ExecuteGitResult, GitCommandError> => | |
| 622 | 680 | execute({ |
| 623 | 681 | operation, |
| 624 | 682 | cwd, |
| 625 | 683 | args, |
| 684 | ...(options.stdin !== undefined ? { stdin: options.stdin } : {}), | |
| 626 | 685 | allowNonZeroExit: true, |
| 627 | 686 | ...(options.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}), |
| 628 | 687 | ...(options.maxOutputBytes !== undefined ? { maxOutputBytes: options.maxOutputBytes } : {}), |
| ... | ... | @@ -679,7 +738,11 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { |
| 679 | 738 | args: readonly string[], |
| 680 | 739 | options: ExecuteGitOptions = {}, |
| 681 | 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 | ); | |
| 683 | 746 | |
| 684 | 747 | const branchExists = (cwd: string, branch: string): Effect.Effect<boolean, GitCommandError> => |
| 685 | 748 | executeGit( |
| ... | ... | @@ -1416,6 +1479,86 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { |
| 1416 | 1479 | Effect.map((trimmed) => (trimmed.length > 0 ? trimmed : null)), |
| 1417 | 1480 | ); |
| 1418 | 1481 | |
| 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 | 1562 | const listBranches: GitCoreShape["listBranches"] = Effect.fn("listBranches")(function* (input) { |
| 1420 | 1563 | const branchRecencyPromise = readBranchRecency(input.cwd).pipe( |
| 1421 | 1564 | Effect.catch(() => Effect.succeed(new Map<string, number>())), |
| ... | ... | @@ -1834,6 +1977,9 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { |
| 1834 | 1977 | pullCurrentBranch, |
| 1835 | 1978 | readRangeContext, |
| 1836 | 1979 | readConfigValue, |
| 1980 | isInsideWorkTree, | |
| 1981 | listWorkspaceFiles, | |
| 1982 | filterIgnoredPaths, | |
| 1837 | 1983 | listBranches, |
| 1838 | 1984 | createWorktree, |
| 1839 | 1985 | fetchPullRequestBranch, |
apps/server/src/git/Services/GitCore.ts+28| ... | ... | @@ -28,6 +28,7 @@ export interface ExecuteGitInput { |
| 28 | 28 | readonly operation: string; |
| 29 | 29 | readonly cwd: string; |
| 30 | 30 | readonly args: ReadonlyArray<string>; |
| 31 | readonly stdin?: string; | |
| 31 | 32 | readonly env?: NodeJS.ProcessEnv; |
| 32 | 33 | readonly allowNonZeroExit?: boolean; |
| 33 | 34 | readonly timeoutMs?: number; |
| ... | ... | @@ -40,6 +41,8 @@ export interface ExecuteGitResult { |
| 40 | 41 | readonly code: number; |
| 41 | 42 | readonly stdout: string; |
| 42 | 43 | readonly stderr: string; |
| 44 | readonly stdoutTruncated: boolean; | |
| 45 | readonly stderrTruncated: boolean; | |
| 43 | 46 | } |
| 44 | 47 | |
| 45 | 48 | export interface GitStatusDetails extends Omit<GitStatusResult, "pr"> { |
| ... | ... | @@ -93,6 +96,11 @@ export interface GitRangeContext { |
| 93 | 96 | diffPatch: string; |
| 94 | 97 | } |
| 95 | 98 | |
| 99 | export interface GitListWorkspaceFilesResult { | |
| 100 | readonly paths: ReadonlyArray<string>; | |
| 101 | readonly truncated: boolean; | |
| 102 | } | |
| 103 | ||
| 96 | 104 | export interface GitRenameBranchInput { |
| 97 | 105 | cwd: string; |
| 98 | 106 | oldBranch: string; |
| ... | ... | @@ -190,6 +198,26 @@ export interface GitCoreShape { |
| 190 | 198 | key: string, |
| 191 | 199 | ) => Effect.Effect<string | null, GitCommandError>; |
| 192 | 200 | |
| 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 | 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 | 38 | } from "../../provider/Services/ProviderService.ts"; |
| 39 | 39 | import { checkpointRefForThreadTurn } from "../../checkpointing/Utils.ts"; |
| 40 | 40 | import { ServerConfig } from "../../config.ts"; |
| 41 | import { WorkspaceEntriesLive } from "../../workspace/Layers/WorkspaceEntries.ts"; | |
| 41 | 42 | |
| 42 | 43 | const asProjectId = (value: string): ProjectId => ProjectId.makeUnsafe(value); |
| 43 | 44 | const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value); |
| ... | ... | @@ -261,7 +262,9 @@ describe("CheckpointReactor", () => { |
| 261 | 262 | Layer.provideMerge(orchestrationLayer), |
| 262 | 263 | Layer.provideMerge(RuntimeReceiptBusLive), |
| 263 | 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 | 268 | Layer.provideMerge(ServerConfigLayer), |
| 266 | 269 | Layer.provideMerge(NodeServices.layer), |
| 267 | 270 | ); |
apps/server/src/orchestration/Layers/CheckpointReactor.ts+4-3| ... | ... | @@ -16,7 +16,6 @@ import { |
| 16 | 16 | checkpointRefForThreadTurn, |
| 17 | 17 | resolveThreadWorkspaceCwd, |
| 18 | 18 | } from "../../checkpointing/Utils.ts"; |
| 19 | import { clearWorkspaceIndexCache } from "../../workspaceEntries.ts"; | |
| 20 | 19 | import { CheckpointStore } from "../../checkpointing/Services/CheckpointStore.ts"; |
| 21 | 20 | import { ProviderService } from "../../provider/Services/ProviderService.ts"; |
| 22 | 21 | import { CheckpointReactor, type CheckpointReactorShape } from "../Services/CheckpointReactor.ts"; |
| ... | ... | @@ -25,6 +24,7 @@ import { RuntimeReceiptBus } from "../Services/RuntimeReceiptBus.ts"; |
| 25 | 24 | import { CheckpointStoreError } from "../../checkpointing/Errors.ts"; |
| 26 | 25 | import { OrchestrationDispatchError } from "../Errors.ts"; |
| 27 | 26 | import { isGitRepository } from "../../git/Utils.ts"; |
| 27 | import { WorkspaceEntries } from "../../workspace/Services/WorkspaceEntries.ts"; | |
| 28 | 28 | |
| 29 | 29 | type ReactorInput = |
| 30 | 30 | | { |
| ... | ... | @@ -68,6 +68,7 @@ const make = Effect.gen(function* () { |
| 68 | 68 | const providerService = yield* ProviderService; |
| 69 | 69 | const checkpointStore = yield* CheckpointStore; |
| 70 | 70 | const receiptBus = yield* RuntimeReceiptBus; |
| 71 | const workspaceEntries = yield* WorkspaceEntries; | |
| 71 | 72 | |
| 72 | 73 | const appendRevertFailureActivity = (input: { |
| 73 | 74 | readonly threadId: ThreadId; |
| ... | ... | @@ -226,7 +227,7 @@ const make = Effect.gen(function* () { |
| 226 | 227 | |
| 227 | 228 | // Invalidate the workspace entry cache so the @-mention file picker |
| 228 | 229 | // reflects files created or deleted during this turn. |
| 229 | clearWorkspaceIndexCache(input.cwd); | |
| 230 | yield* workspaceEntries.invalidate(input.cwd); | |
| 230 | 231 | |
| 231 | 232 | const files = yield* checkpointStore |
| 232 | 233 | .diffCheckpoints({ |
| ... | ... | @@ -642,7 +643,7 @@ const make = Effect.gen(function* () { |
| 642 | 643 | |
| 643 | 644 | // Invalidate the workspace entry cache so the @-mention file picker |
| 644 | 645 | // reflects the reverted filesystem state. |
| 645 | clearWorkspaceIndexCache(sessionRuntime.value.cwd); | |
| 646 | yield* workspaceEntries.invalidate(sessionRuntime.value.cwd); | |
| 646 | 647 | |
| 647 | 648 | const rolledBackTurns = Math.max(0, currentTurnCount - event.payload.turnCount); |
| 648 | 649 | if (rolledBackTurns > 0) { |
apps/server/src/project/Layers/ProjectFaviconResolver.test.ts created+74| ... | ... | @@ -0,0 +1,74 @@ |
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 2 | import { it, describe, expect } from "@effect/vitest"; | |
| 3 | import { Effect, FileSystem, Layer, Path } from "effect"; | |
| 4 | ||
| 5 | import { ProjectFaviconResolver } from "../Services/ProjectFaviconResolver.ts"; | |
| 6 | import { ProjectFaviconResolverLive } from "./ProjectFaviconResolver.ts"; | |
| 7 | ||
| 8 | const TestLayer = Layer.empty.pipe( | |
| 9 | Layer.provideMerge(ProjectFaviconResolverLive), | |
| 10 | Layer.provideMerge(NodeServices.layer), | |
| 11 | ); | |
| 12 | ||
| 13 | const makeTempDir = Effect.gen(function* () { | |
| 14 | const fileSystem = yield* FileSystem.FileSystem; | |
| 15 | return yield* fileSystem.makeTempDirectoryScoped({ | |
| 16 | prefix: "t3code-project-favicon-", | |
| 17 | }); | |
| 18 | }); | |
| 19 | ||
| 20 | const 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 | ||
| 34 | it.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 @@ |
| 1 | import { Effect, FileSystem, Layer, Path } from "effect"; | |
| 2 | ||
| 3 | import { | |
| 4 | ProjectFaviconResolver, | |
| 5 | type ProjectFaviconResolverShape, | |
| 6 | } from "../Services/ProjectFaviconResolver.ts"; | |
| 7 | ||
| 8 | // Well-known favicon paths checked in order. | |
| 9 | const 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. | |
| 33 | const 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. | |
| 44 | const LINK_ICON_HTML_RE = | |
| 45 | /<link\b(?=[^>]*\brel=["'](?:icon|shortcut icon)["'])(?=[^>]*\bhref=["']([^"'?]+))[^>]*>/i; | |
| 46 | const LINK_ICON_OBJ_RE = | |
| 47 | /(?=[^}]*\brel\s*:\s*["'](?:icon|shortcut icon)["'])(?=[^}]*\bhref\s*:\s*["']([^"'?]+))[^}]*/i; | |
| 48 | ||
| 49 | function 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 | ||
| 57 | export 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 | ||
| 126 | export 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 | */ | |
| 9 | import { ServiceMap } from "effect"; | |
| 10 | import type { Effect } from "effect"; | |
| 11 | ||
| 12 | /** | |
| 13 | * ProjectFaviconResolverShape - Service API for project favicon lookup. | |
| 14 | */ | |
| 15 | export interface ProjectFaviconResolverShape { | |
| 16 | /** | |
| 17 | * Resolve a favicon or icon file path for the provided workspace root. | |
| 18 | * | |
| 19 | * Returns `null` when no candidate icon file can be found. | |
| 20 | */ | |
| 21 | readonly resolvePath: (cwd: string) => Effect.Effect<string | null>; | |
| 22 | } | |
| 23 | ||
| 24 | /** | |
| 25 | * ProjectFaviconResolver - Service tag for project favicon resolution. | |
| 26 | */ | |
| 27 | export class ProjectFaviconResolver extends 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 | 3 | import os from "node:os"; |
| 4 | 4 | import path from "node:path"; |
| 5 | 5 | |
| 6 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 7 | import { Effect } from "effect"; | |
| 6 | 8 | import { afterEach, describe, expect, it } from "vitest"; |
| 9 | ||
| 10 | import { ProjectFaviconResolverLive } from "./project/Layers/ProjectFaviconResolver.ts"; | |
| 7 | 11 | import { tryHandleProjectFaviconRequest } from "./projectFaviconRoute"; |
| 8 | 12 | |
| 9 | 13 | interface HttpResponse { |
| ... | ... | @@ -23,11 +27,25 @@ function makeTempDir(prefix: string): string { |
| 23 | 27 | async function withRouteServer(run: (baseUrl: string) => Promise<void>): Promise<void> { |
| 24 | 28 | const server = http.createServer((req, res) => { |
| 25 | 29 | const url = new URL(req.url ?? "/", "http://127.0.0.1"); |
| 26 | if (tryHandleProjectFaviconRequest(url, res)) { | |
| 27 | return; | |
| 28 | } | |
| 29 | res.writeHead(404, { "Content-Type": "text/plain" }); | |
| 30 | res.end("Not Found"); | |
| 30 | void Effect.runPromise( | |
| 31 | tryHandleProjectFaviconRequest(url, res).pipe( | |
| 32 | Effect.provide(ProjectFaviconResolverLive), | |
| 33 | Effect.provide(NodeServices.layer), | |
| 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 | }); |
| 32 | 50 | |
| 33 | 51 | await new Promise<void>((resolve, reject) => { |
apps/server/src/projectFaviconRoute.ts+56-151| ... | ... | @@ -1,6 +1,8 @@ |
| 1 | import fs from "node:fs"; | |
| 2 | 1 | import http from "node:http"; |
| 3 | 2 | import path from "node:path"; |
| 3 | import { Effect, FileSystem } from "effect"; | |
| 4 | ||
| 5 | import { ProjectFaviconResolver } from "./project/Services/ProjectFaviconResolver.ts"; | |
| 4 | 6 | |
| 5 | 7 | const FAVICON_MIME_TYPES: Record<string, string> = { |
| 6 | 8 | ".png": "image/png", |
| ... | ... | @@ -11,161 +13,64 @@ const FAVICON_MIME_TYPES: Record<string, string> = { |
| 11 | 13 | |
| 12 | 14 | const 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>`; |
| 13 | 15 | |
| 14 | // Well-known favicon paths checked in order. | |
| 15 | const FAVICON_CANDIDATES = [ | |
| 16 | "favicon.svg", | |
| 17 | "favicon.ico", | |
| 18 | "favicon.png", | |
| 19 | "public/favicon.svg", | |
| 20 | "public/favicon.ico", | |
| 21 | "public/favicon.png", | |
| 22 | "app/favicon.ico", | |
| 23 | "app/favicon.png", | |
| 24 | "app/icon.svg", | |
| 25 | "app/icon.png", | |
| 26 | "app/icon.ico", | |
| 27 | "src/favicon.ico", | |
| 28 | "src/favicon.svg", | |
| 29 | "src/app/favicon.ico", | |
| 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. | |
| 39 | const 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. | |
| 50 | const LINK_ICON_HTML_RE = | |
| 51 | /<link\b(?=[^>]*\brel=["'](?:icon|shortcut icon)["'])(?=[^>]*\bhref=["']([^"'?]+))[^>]*>/i; | |
| 52 | const LINK_ICON_OBJ_RE = | |
| 53 | /(?=[^}]*\brel\s*:\s*["'](?:icon|shortcut icon)["'])(?=[^}]*\bhref\s*:\s*["']([^"'?]+))[^}]*/i; | |
| 54 | ||
| 55 | function 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 | ||
| 63 | function resolveIconHref(projectCwd: string, href: string): string[] { | |
| 64 | const clean = href.replace(/^\//, ""); | |
| 65 | return [path.join(projectCwd, "public", clean), path.join(projectCwd, clean)]; | |
| 66 | } | |
| 67 | ||
| 68 | function 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 | ||
| 73 | function 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; | |
| 16 | export const tryHandleProjectFaviconRequest = Effect.fn("tryHandleProjectFaviconRequest")( | |
| 17 | function* ( | |
| 18 | url: URL, | |
| 19 | res: http.ServerResponse, | |
| 20 | ): Effect.fn.Return<boolean, never, ProjectFaviconResolver | FileSystem.FileSystem> { | |
| 21 | const respond = ( | |
| 22 | statusCode: number, | |
| 23 | headers: Record<string, string>, | |
| 24 | body: string | Uint8Array, | |
| 25 | ) => { | |
| 26 | res.writeHead(statusCode, headers); | |
| 27 | res.end(body); | |
| 28 | }; | |
| 29 | ||
| 30 | if (url.pathname !== "/api/project-favicon") { | |
| 31 | return false; | |
| 81 | 32 | } |
| 82 | res.writeHead(200, { | |
| 83 | "Content-Type": contentType, | |
| 84 | "Cache-Control": "public, max-age=3600", | |
| 85 | }); | |
| 86 | res.end(data); | |
| 87 | }); | |
| 88 | } | |
| 89 | 33 | |
| 90 | function serveFallbackFavicon(res: http.ServerResponse): void { | |
| 91 | res.writeHead(200, { | |
| 92 | "Content-Type": "image/svg+xml", | |
| 93 | "Cache-Control": "public, max-age=3600", | |
| 94 | }); | |
| 95 | res.end(FALLBACK_FAVICON_SVG); | |
| 96 | } | |
| 97 | ||
| 98 | export 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; | |
| 34 | const projectCwd = url.searchParams.get("cwd"); | |
| 35 | if (!projectCwd) { | |
| 36 | respond(400, { "Content-Type": "text/plain" }, "Missing cwd parameter"); | |
| 37 | return true; | |
| 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 | }; | |
| 128 | 39 | |
| 129 | const trySourceFiles = (index: number): void => { | |
| 130 | if (index >= ICON_SOURCE_FILES.length) { | |
| 131 | serveFallbackFavicon(res); | |
| 132 | return; | |
| 40 | const fileSystem = yield* FileSystem.FileSystem; | |
| 41 | const faviconResolver = yield* ProjectFaviconResolver; | |
| 42 | const resolvedPath = yield* faviconResolver.resolvePath(projectCwd); | |
| 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 | }; | |
| 149 | 55 | |
| 150 | const tryCandidates = (index: number): void => { | |
| 151 | if (index >= FAVICON_CANDIDATES.length) { | |
| 152 | trySourceFiles(0); | |
| 153 | return; | |
| 154 | } | |
| 155 | const candidate = path.join(projectCwd, FAVICON_CANDIDATES[index]!); | |
| 156 | if (!isPathWithinProject(projectCwd, candidate)) { | |
| 157 | tryCandidates(index + 1); | |
| 158 | return; | |
| 56 | const data = yield* fileSystem | |
| 57 | .readFile(resolvedPath) | |
| 58 | .pipe(Effect.catch(() => Effect.succeed(null))); | |
| 59 | if (!data) { | |
| 60 | respond(500, { "Content-Type": "text/plain" }, "Read error"); | |
| 61 | return true; | |
| 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 | }; | |
| 168 | 63 | |
| 169 | tryCandidates(0); | |
| 170 | return true; | |
| 171 | } | |
| 64 | const ext = path.extname(resolvedPath).toLowerCase(); | |
| 65 | const contentType = FAVICON_MIME_TYPES[ext] ?? "application/octet-stream"; | |
| 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 | 34 | import { RoutingTextGenerationLive } from "./git/Layers/RoutingTextGeneration"; |
| 35 | 35 | import { PtyAdapter } from "./terminal/Services/PTY"; |
| 36 | 36 | import { AnalyticsService } from "./telemetry/Services/AnalyticsService"; |
| 37 | import { ProjectFaviconResolverLive } from "./project/Layers/ProjectFaviconResolver.ts"; | |
| 38 | import { WorkspaceEntriesLive } from "./workspace/Layers/WorkspaceEntries.ts"; | |
| 39 | import { WorkspaceFileSystemLive } from "./workspace/Layers/WorkspaceFileSystem.ts"; | |
| 40 | import { WorkspacePathsLive } from "./workspace/Layers/WorkspacePaths.ts"; | |
| 37 | 41 | |
| 38 | 42 | type RuntimePtyAdapterLoader = { |
| 39 | 43 | layer: Layer.Layer<PtyAdapter, never, FileSystem.FileSystem | Path.Path>; |
| ... | ... | @@ -91,7 +95,8 @@ export function makeServerProviderLayer(): Layer.Layer< |
| 91 | 95 | |
| 92 | 96 | export function makeServerRuntimeServicesLayer() { |
| 93 | 97 | const textGenerationLayer = RoutingTextGenerationLive; |
| 94 | const checkpointStoreLayer = CheckpointStoreLive.pipe(Layer.provide(GitCoreLive)); | |
| 98 | const gitCoreLayer = GitCoreLive; | |
| 99 | const checkpointStoreLayer = CheckpointStoreLive; | |
| 95 | 100 | |
| 96 | 101 | const orchestrationLayer = OrchestrationEngineLive.pipe( |
| 97 | 102 | Layer.provide(OrchestrationProjectionPipelineLive), |
| ... | ... | @@ -116,11 +121,11 @@ export function makeServerRuntimeServicesLayer() { |
| 116 | 121 | ); |
| 117 | 122 | const providerCommandReactorLayer = ProviderCommandReactorLive.pipe( |
| 118 | 123 | Layer.provideMerge(runtimeServicesLayer), |
| 119 | Layer.provideMerge(GitCoreLive), | |
| 120 | 124 | Layer.provideMerge(textGenerationLayer), |
| 121 | 125 | ); |
| 122 | 126 | const checkpointReactorLayer = CheckpointReactorLive.pipe( |
| 123 | 127 | Layer.provideMerge(runtimeServicesLayer), |
| 128 | Layer.provideMerge(WorkspaceEntriesLive), | |
| 124 | 129 | ); |
| 125 | 130 | const orchestrationReactorLayer = OrchestrationReactorLive.pipe( |
| 126 | 131 | Layer.provideMerge(runtimeIngestionLayer), |
| ... | ... | @@ -131,16 +136,26 @@ export function makeServerRuntimeServicesLayer() { |
| 131 | 136 | const terminalLayer = TerminalManagerLive.pipe(Layer.provide(makeRuntimePtyAdapterLayer())); |
| 132 | 137 | |
| 133 | 138 | const gitManagerLayer = GitManagerLive.pipe( |
| 134 | Layer.provideMerge(GitCoreLive), | |
| 135 | 139 | Layer.provideMerge(GitHubCliLive), |
| 136 | 140 | Layer.provideMerge(textGenerationLayer), |
| 137 | 141 | ); |
| 138 | 142 | |
| 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 | 151 | return Layer.mergeAll( |
| 140 | 152 | orchestrationReactorLayer, |
| 141 | GitCoreLive, | |
| 153 | workspacePathsLayer, | |
| 154 | workspaceEntriesLayer, | |
| 155 | workspaceFileSystemLayer, | |
| 156 | projectFaviconResolverLayer, | |
| 142 | 157 | gitManagerLayer, |
| 143 | 158 | terminalLayer, |
| 144 | 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 @@ |
| 1 | import fsPromises from "node:fs/promises"; | |
| 2 | ||
| 3 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 4 | import { it, afterEach, describe, expect, vi } from "@effect/vitest"; | |
| 5 | import { Effect, FileSystem, Layer, Path, PlatformError } from "effect"; | |
| 6 | ||
| 7 | import { ServerConfig } from "../../config.ts"; | |
| 8 | import { GitCoreLive } from "../../git/Layers/GitCore.ts"; | |
| 9 | import { GitCore } from "../../git/Services/GitCore.ts"; | |
| 10 | import { WorkspaceEntries } from "../Services/WorkspaceEntries.ts"; | |
| 11 | import { WorkspaceEntriesLive } from "./WorkspaceEntries.ts"; | |
| 12 | ||
| 13 | const 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 | ||
| 24 | const 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 | ||
| 36 | function 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 | ||
| 50 | const 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 | ||
| 63 | const 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 | ||
| 69 | it.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 @@ |
| 1 | import fsPromises from "node:fs/promises"; | |
| 2 | import type { Dirent } from "node:fs"; | |
| 3 | ||
| 4 | import { Cache, Duration, Effect, Exit, Layer, Option, Path } from "effect"; | |
| 5 | ||
| 6 | import { type ProjectEntry } from "@t3tools/contracts"; | |
| 7 | ||
| 8 | import { GitCore } from "../../git/Services/GitCore.ts"; | |
| 9 | import { | |
| 10 | WorkspaceEntries, | |
| 11 | WorkspaceEntriesError, | |
| 12 | type WorkspaceEntriesShape, | |
| 13 | } from "../Services/WorkspaceEntries.ts"; | |
| 14 | ||
| 15 | const WORKSPACE_CACHE_TTL_MS = 15_000; | |
| 16 | const WORKSPACE_CACHE_MAX_KEYS = 4; | |
| 17 | const WORKSPACE_INDEX_MAX_ENTRIES = 25_000; | |
| 18 | const WORKSPACE_SCAN_READDIR_CONCURRENCY = 32; | |
| 19 | const 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 | ||
| 31 | interface WorkspaceIndex { | |
| 32 | scannedAt: number; | |
| 33 | entries: SearchableWorkspaceEntry[]; | |
| 34 | truncated: boolean; | |
| 35 | } | |
| 36 | ||
| 37 | interface SearchableWorkspaceEntry extends ProjectEntry { | |
| 38 | normalizedPath: string; | |
| 39 | normalizedName: string; | |
| 40 | } | |
| 41 | ||
| 42 | interface RankedWorkspaceEntry { | |
| 43 | entry: SearchableWorkspaceEntry; | |
| 44 | score: number; | |
| 45 | } | |
| 46 | ||
| 47 | function toPosixPath(input: string): string { | |
| 48 | return input.replaceAll("\\", "/"); | |
| 49 | } | |
| 50 | ||
| 51 | function 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 | ||
| 59 | function 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 | ||
| 67 | function toSearchableWorkspaceEntry(entry: ProjectEntry): SearchableWorkspaceEntry { | |
| 68 | const normalizedPath = entry.path.toLowerCase(); | |
| 69 | return { | |
| 70 | ...entry, | |
| 71 | normalizedPath, | |
| 72 | normalizedName: basenameOf(normalizedPath), | |
| 73 | }; | |
| 74 | } | |
| 75 | ||
| 76 | function normalizeQuery(input: string): string { | |
| 77 | return input | |
| 78 | .trim() | |
| 79 | .replace(/^[@./]+/, "") | |
| 80 | .toLowerCase(); | |
| 81 | } | |
| 82 | ||
| 83 | function 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 | ||
| 115 | function 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 | ||
| 143 | function 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 | ||
| 152 | function 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 | ||
| 176 | function 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 | ||
| 199 | function isPathInIgnoredDirectory(relativePath: string): boolean { | |
| 200 | const firstSegment = relativePath.split("/")[0]; | |
| 201 | if (!firstSegment) return false; | |
| 202 | return IGNORED_DIRECTORY_NAMES.has(firstSegment); | |
| 203 | } | |
| 204 | ||
| 205 | function 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 | ||
| 216 | const processErrorDetail = (cause: unknown): string => | |
| 217 | cause instanceof Error ? cause.message : String(cause); | |
| 218 | ||
| 219 | export 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 | ||
| 478 | export const WorkspaceEntriesLive = Layer.effect(WorkspaceEntries, makeWorkspaceEntries); |
apps/server/src/workspace/Layers/WorkspaceFileSystem.test.ts created+135| ... | ... | @@ -0,0 +1,135 @@ |
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 2 | import { it, describe, expect } from "@effect/vitest"; | |
| 3 | import { Effect, FileSystem, Layer, Path } from "effect"; | |
| 4 | ||
| 5 | import { ServerConfig } from "../../config.ts"; | |
| 6 | import { GitCoreLive } from "../../git/Layers/GitCore.ts"; | |
| 7 | import { WorkspaceEntries } from "../Services/WorkspaceEntries.ts"; | |
| 8 | import { WorkspaceFileSystem } from "../Services/WorkspaceFileSystem.ts"; | |
| 9 | import { WorkspaceEntriesLive } from "./WorkspaceEntries.ts"; | |
| 10 | import { WorkspaceFileSystemLive } from "./WorkspaceFileSystem.ts"; | |
| 11 | import { WorkspacePathsLive } from "./WorkspacePaths.ts"; | |
| 12 | ||
| 13 | const ProjectLayer = WorkspaceFileSystemLive.pipe( | |
| 14 | Layer.provide(WorkspacePathsLive), | |
| 15 | Layer.provide(WorkspaceEntriesLive), | |
| 16 | ); | |
| 17 | ||
| 18 | const 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 | ||
| 30 | const makeTempDir = Effect.gen(function* () { | |
| 31 | const fileSystem = yield* FileSystem.FileSystem; | |
| 32 | return yield* fileSystem.makeTempDirectoryScoped({ | |
| 33 | prefix: "t3code-workspace-files-", | |
| 34 | }); | |
| 35 | }); | |
| 36 | ||
| 37 | const 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 | ||
| 51 | it.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 @@ |
| 1 | import { Effect, FileSystem, Layer, Path } from "effect"; | |
| 2 | ||
| 3 | import { | |
| 4 | WorkspaceFileSystem, | |
| 5 | WorkspaceFileSystemError, | |
| 6 | type WorkspaceFileSystemShape, | |
| 7 | } from "../Services/WorkspaceFileSystem.ts"; | |
| 8 | import { WorkspaceEntries } from "../Services/WorkspaceEntries.ts"; | |
| 9 | import { WorkspacePaths } from "../Services/WorkspacePaths.ts"; | |
| 10 | ||
| 11 | export 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 | ||
| 55 | export const WorkspaceFileSystemLive = Layer.effect(WorkspaceFileSystem, makeWorkspaceFileSystem); |
apps/server/src/workspace/Layers/WorkspacePaths.test.ts created+113| ... | ... | @@ -0,0 +1,113 @@ |
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 2 | import { it, describe, expect } from "@effect/vitest"; | |
| 3 | import { Effect, FileSystem, Layer, Path } from "effect"; | |
| 4 | ||
| 5 | import { WorkspacePaths } from "../Services/WorkspacePaths.ts"; | |
| 6 | import { WorkspacePathsLive } from "./WorkspacePaths.ts"; | |
| 7 | ||
| 8 | const TestLayer = Layer.empty.pipe( | |
| 9 | Layer.provideMerge(WorkspacePathsLive), | |
| 10 | Layer.provideMerge(NodeServices.layer), | |
| 11 | ); | |
| 12 | ||
| 13 | const makeTempDir = Effect.fn("makeTempDir")(function* () { | |
| 14 | const fileSystem = yield* FileSystem.FileSystem; | |
| 15 | return yield* fileSystem.makeTempDirectoryScoped({ | |
| 16 | prefix: "t3code-project-paths-", | |
| 17 | }); | |
| 18 | }); | |
| 19 | ||
| 20 | const 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 | ||
| 34 | it.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 @@ |
| 1 | import * as OS from "node:os"; | |
| 2 | import { Effect, FileSystem, Layer, Path } from "effect"; | |
| 3 | ||
| 4 | import { | |
| 5 | WorkspacePaths, | |
| 6 | WorkspacePathOutsideRootError, | |
| 7 | WorkspaceRootNotDirectoryError, | |
| 8 | WorkspaceRootNotExistsError, | |
| 9 | type WorkspacePathsShape, | |
| 10 | } from "../Services/WorkspacePaths.ts"; | |
| 11 | ||
| 12 | function toPosixRelativePath(input: string): string { | |
| 13 | return input.replaceAll("\\", "/"); | |
| 14 | } | |
| 15 | ||
| 16 | function 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 | ||
| 26 | export 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 | ||
| 89 | export 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 | */ | |
| 9 | import { Schema, ServiceMap } from "effect"; | |
| 10 | import type { Effect } from "effect"; | |
| 11 | ||
| 12 | import type { ProjectSearchEntriesInput, ProjectSearchEntriesResult } from "@t3tools/contracts"; | |
| 13 | ||
| 14 | export 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 | */ | |
| 28 | export 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 | */ | |
| 46 | export 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 | */ | |
| 9 | import { Schema, ServiceMap } from "effect"; | |
| 10 | import type { Effect } from "effect"; | |
| 11 | ||
| 12 | import type { ProjectWriteFileInput, ProjectWriteFileResult } from "@t3tools/contracts"; | |
| 13 | import { WorkspacePathOutsideRootError } from "./WorkspacePaths.ts"; | |
| 14 | ||
| 15 | export 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 | */ | |
| 29 | export 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 | */ | |
| 47 | export 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 | */ | |
| 9 | import { Schema, ServiceMap } from "effect"; | |
| 10 | import type { Effect } from "effect"; | |
| 11 | ||
| 12 | export 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 | ||
| 24 | export 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 | ||
| 36 | export 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 | ||
| 48 | export const WorkspacePathsError = Schema.Union([ | |
| 49 | WorkspaceRootNotExistsError, | |
| 50 | WorkspaceRootNotDirectoryError, | |
| 51 | WorkspacePathOutsideRootError, | |
| 52 | ]); | |
| 53 | export type WorkspacePathsError = typeof WorkspacePathsError.Type; | |
| 54 | ||
| 55 | /** | |
| 56 | * WorkspacePathsShape - Service API for workspace path normalization and guards. | |
| 57 | */ | |
| 58 | export 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 | */ | |
| 83 | export 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 @@ |
| 1 | import { assert, beforeEach, describe, it, vi } from "vitest"; | |
| 2 | import type { ProcessRunOptions, ProcessRunResult } from "./processRunner"; | |
| 3 | ||
| 4 | const { 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 | ||
| 15 | vi.mock("./processRunner", () => ({ | |
| 16 | runProcess: runProcessMock, | |
| 17 | })); | |
| 18 | ||
| 19 | function 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 | ||
| 33 | describe("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 @@ |
| 1 | import fs from "node:fs"; | |
| 2 | import fsPromises from "node:fs/promises"; | |
| 3 | import os from "node:os"; | |
| 4 | import path from "node:path"; | |
| 5 | import { spawnSync } from "node:child_process"; | |
| 6 | ||
| 7 | import { afterEach, assert, describe, it, vi } from "vitest"; | |
| 8 | ||
| 9 | import { searchWorkspaceEntries } from "./workspaceEntries"; | |
| 10 | ||
| 11 | const tempDirs: string[] = []; | |
| 12 | ||
| 13 | function makeTempDir(prefix: string): string { | |
| 14 | const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); | |
| 15 | tempDirs.push(dir); | |
| 16 | return dir; | |
| 17 | } | |
| 18 | ||
| 19 | function 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 | ||
| 25 | function 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 | ||
| 32 | describe("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 @@ |
| 1 | import fs from "node:fs/promises"; | |
| 2 | import type { Dirent } from "node:fs"; | |
| 3 | import path from "node:path"; | |
| 4 | import { runProcess } from "./processRunner"; | |
| 5 | ||
| 6 | import { | |
| 7 | ProjectEntry, | |
| 8 | ProjectSearchEntriesInput, | |
| 9 | ProjectSearchEntriesResult, | |
| 10 | } from "@t3tools/contracts"; | |
| 11 | ||
| 12 | const WORKSPACE_CACHE_TTL_MS = 15_000; | |
| 13 | const WORKSPACE_CACHE_MAX_KEYS = 4; | |
| 14 | const WORKSPACE_INDEX_MAX_ENTRIES = 25_000; | |
| 15 | const WORKSPACE_SCAN_READDIR_CONCURRENCY = 32; | |
| 16 | const GIT_CHECK_IGNORE_MAX_STDIN_BYTES = 256 * 1024; | |
| 17 | const 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 | ||
| 29 | interface WorkspaceIndex { | |
| 30 | scannedAt: number; | |
| 31 | entries: SearchableWorkspaceEntry[]; | |
| 32 | truncated: boolean; | |
| 33 | } | |
| 34 | ||
| 35 | interface SearchableWorkspaceEntry extends ProjectEntry { | |
| 36 | normalizedPath: string; | |
| 37 | normalizedName: string; | |
| 38 | } | |
| 39 | ||
| 40 | interface RankedWorkspaceEntry { | |
| 41 | entry: SearchableWorkspaceEntry; | |
| 42 | score: number; | |
| 43 | } | |
| 44 | ||
| 45 | const workspaceIndexCache = new Map<string, WorkspaceIndex>(); | |
| 46 | const inFlightWorkspaceIndexBuilds = new Map<string, Promise<WorkspaceIndex>>(); | |
| 47 | ||
| 48 | function toPosixPath(input: string): string { | |
| 49 | return input.split(path.sep).join("/"); | |
| 50 | } | |
| 51 | ||
| 52 | function 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 | ||
| 60 | function 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 | ||
| 68 | function toSearchableWorkspaceEntry(entry: ProjectEntry): SearchableWorkspaceEntry { | |
| 69 | const normalizedPath = entry.path.toLowerCase(); | |
| 70 | return { | |
| 71 | ...entry, | |
| 72 | normalizedPath, | |
| 73 | normalizedName: basenameOf(normalizedPath), | |
| 74 | }; | |
| 75 | } | |
| 76 | ||
| 77 | function normalizeQuery(input: string): string { | |
| 78 | return input | |
| 79 | .trim() | |
| 80 | .replace(/^[@./]+/, "") | |
| 81 | .toLowerCase(); | |
| 82 | } | |
| 83 | ||
| 84 | function 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 | ||
| 116 | function 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 | ||
| 144 | function 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 | ||
| 153 | function 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 | ||
| 177 | function 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 | ||
| 200 | function isPathInIgnoredDirectory(relativePath: string): boolean { | |
| 201 | const firstSegment = relativePath.split("/")[0]; | |
| 202 | if (!firstSegment) return false; | |
| 203 | return IGNORED_DIRECTORY_NAMES.has(firstSegment); | |
| 204 | } | |
| 205 | ||
| 206 | function 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 | ||
| 218 | function 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 | ||
| 228 | async 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 | ||
| 253 | async 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 | ||
| 265 | async 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 | ||
| 338 | async 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 | ||
| 404 | async 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 | ||
| 509 | async 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 | ||
| 537 | export function clearWorkspaceIndexCache(cwd: string): void { | |
| 538 | workspaceIndexCache.delete(cwd); | |
| 539 | inFlightWorkspaceIndexBuilds.delete(cwd); | |
| 540 | } | |
| 541 | ||
| 542 | export 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 | 4 | import path from "node:path"; |
| 5 | 5 | |
| 6 | 6 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 7 | import { Effect, Exit, Fiber, Layer, PlatformError, PubSub, Scope, Stream } from "effect"; | |
| 7 | import { | |
| 8 | Effect, | |
| 9 | Exit, | |
| 10 | Fiber, | |
| 11 | Layer, | |
| 12 | ManagedRuntime, | |
| 13 | PlatformError, | |
| 14 | PubSub, | |
| 15 | Scope, | |
| 16 | Stream, | |
| 17 | } from "effect"; | |
| 8 | 18 | import { describe, expect, it, afterEach, vi } from "vitest"; |
| 9 | 19 | import { createServer } from "./wsServer"; |
| 10 | 20 | import WebSocket from "ws"; |
| ... | ... | @@ -475,6 +485,7 @@ function deriveServerPathsSync(baseDir: string, devUrl: URL | undefined) { |
| 475 | 485 | describe("WebSocket Server", () => { |
| 476 | 486 | let server: Http.Server | null = null; |
| 477 | 487 | let serverScope: Scope.Closeable | null = null; |
| 488 | let disposeServerRuntime: (() => Promise<void>) | null = null; | |
| 478 | 489 | const connections: WebSocket[] = []; |
| 479 | 490 | const tempDirs: string[] = []; |
| 480 | 491 | |
| ... | ... | @@ -521,6 +532,12 @@ describe("WebSocket Server", () => { |
| 521 | 532 | options.providerRegistry ?? defaultProviderRegistryService, |
| 522 | 533 | ); |
| 523 | 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 | 541 | const serverConfigLayer = Layer.succeed(ServerConfig, { |
| 525 | 542 | mode: "web", |
| 526 | 543 | port: 0, |
| ... | ... | @@ -536,6 +553,11 @@ describe("WebSocket Server", () => { |
| 536 | 553 | logWebSocketEvents: options.logWebSocketEvents ?? Boolean(options.devUrl), |
| 537 | 554 | } satisfies ServerConfigShape); |
| 538 | 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 | 561 | const runtimeOverrides = Layer.mergeAll( |
| 540 | 562 | options.gitManager ? Layer.succeed(GitManager, options.gitManager) : Layer.empty, |
| 541 | 563 | options.gitCore |
| ... | ... | @@ -548,41 +570,49 @@ describe("WebSocket Server", () => { |
| 548 | 570 | |
| 549 | 571 | const runtimeLayer = Layer.merge( |
| 550 | 572 | Layer.merge( |
| 551 | makeServerRuntimeServicesLayer().pipe(Layer.provide(infrastructureLayer)), | |
| 552 | infrastructureLayer, | |
| 573 | makeServerRuntimeServicesLayer().pipe( | |
| 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 | 582 | runtimeOverrides, |
| 555 | 583 | ); |
| 556 | const dependenciesLayer = Layer.empty.pipe( | |
| 557 | Layer.provideMerge(runtimeLayer), | |
| 558 | Layer.provideMerge(providerRegistryLayer), | |
| 559 | Layer.provideMerge(openLayer), | |
| 560 | Layer.provideMerge(ServerSettingsService.layerTest(options.serverSettings)), | |
| 561 | Layer.provideMerge(serverConfigLayer), | |
| 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)), | |
| 584 | const dependenciesLayer = Layer.mergeAll( | |
| 585 | runtimeLayer, | |
| 586 | providerRegistryLayer, | |
| 587 | openLayer, | |
| 588 | serverConfigLayer, | |
| 589 | nodeServicesLayer, | |
| 567 | 590 | ); |
| 568 | ||
| 591 | const runtime = ManagedRuntime.make(dependenciesLayer); | |
| 569 | 592 | try { |
| 570 | const runtime = await Effect.runPromise( | |
| 571 | createServer().pipe(Effect.provide(runtimeServices), Scope.provide(scope)), | |
| 572 | ); | |
| 593 | const httpServer = await runtime.runPromise(createServer().pipe(Scope.provide(scope))); | |
| 594 | disposeServerRuntime = () => runtime.dispose(); | |
| 573 | 595 | serverScope = scope; |
| 574 | return runtime; | |
| 596 | return httpServer; | |
| 575 | 597 | } catch (error) { |
| 598 | await runtime.dispose(); | |
| 576 | 599 | await Effect.runPromise(Scope.close(scope, Exit.void)); |
| 577 | 600 | throw error; |
| 578 | 601 | } |
| 579 | 602 | } |
| 580 | 603 | |
| 581 | 604 | async function closeTestServer() { |
| 582 | if (!serverScope) return; | |
| 605 | if (!serverScope && !disposeServerRuntime) return; | |
| 583 | 606 | const scope = serverScope; |
| 607 | const disposeRuntime = disposeServerRuntime; | |
| 584 | 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 | } |
| 587 | 617 | |
| 588 | 618 | afterEach(async () => { |
| ... | ... | @@ -1255,6 +1285,32 @@ describe("WebSocket Server", () => { |
| 1255 | 1285 | expect(response.error?.message).toContain("exceeds current turn count"); |
| 1256 | 1286 | }); |
| 1257 | 1287 | |
| 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 | 1314 | it("keeps orchestration domain push behavior for provider runtime events", async () => { |
| 1259 | 1315 | const runtimeEventPubSub = Effect.runSync(PubSub.unbounded<ProviderRuntimeEvent>()); |
| 1260 | 1316 | const emitRuntimeEvent = (event: ProviderRuntimeEvent) => { |
| ... | ... | @@ -1665,6 +1721,50 @@ describe("WebSocket Server", () => { |
| 1665 | 1721 | ); |
| 1666 | 1722 | }); |
| 1667 | 1723 | |
| 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 | 1768 | it("rejects projects.writeFile paths outside the workspace root", async () => { |
| 1669 | 1769 | const workspace = makeTempDir("t3code-ws-write-file-reject-"); |
| 1670 | 1770 | |
| ... | ... | @@ -1683,7 +1783,7 @@ describe("WebSocket Server", () => { |
| 1683 | 1783 | |
| 1684 | 1784 | expect(response.result).toBeUndefined(); |
| 1685 | 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 | 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 | 50 | import { TerminalManager } from "./terminal/Services/Manager.ts"; |
| 51 | 51 | import { Keybindings } from "./keybindings"; |
| 52 | 52 | import { ServerSettingsService } from "./serverSettings"; |
| 53 | import { searchWorkspaceEntries } from "./workspaceEntries"; | |
| 54 | 53 | import { OrchestrationEngineService } from "./orchestration/Services/OrchestrationEngine"; |
| 55 | 54 | import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery"; |
| 56 | 55 | import { OrchestrationReactor } from "./orchestration/Services/OrchestrationReactor"; |
| ... | ... | @@ -75,10 +74,13 @@ import { |
| 75 | 74 | } from "./attachmentStore.ts"; |
| 76 | 75 | import { parseBase64DataUrl } from "./imageMime.ts"; |
| 77 | 76 | import { AnalyticsService } from "./telemetry/Services/AnalyticsService.ts"; |
| 78 | import { expandHomePath } from "./os-jank.ts"; | |
| 79 | 77 | import { makeServerPushBus } from "./wsServer/pushBus.ts"; |
| 80 | 78 | import { makeServerReadiness } from "./wsServer/readiness.ts"; |
| 81 | 79 | import { decodeJsonResult, formatSchemaError } from "@t3tools/shared/schemaJson"; |
| 80 | import { ProjectFaviconResolver } from "./project/Services/ProjectFaviconResolver.ts"; | |
| 81 | import { WorkspaceEntries } from "./workspace/Services/WorkspaceEntries.ts"; | |
| 82 | import { WorkspaceFileSystem } from "./workspace/Services/WorkspaceFileSystem.ts"; | |
| 83 | import { WorkspacePaths } from "./workspace/Services/WorkspacePaths.ts"; | |
| 82 | 84 | |
| 83 | 85 | /** |
| 84 | 86 | * ServerShape - Service API for server lifecycle control. |
| ... | ... | @@ -154,48 +156,6 @@ function websocketRawToString(raw: unknown): string | null { |
| 154 | 156 | return null; |
| 155 | 157 | } |
| 156 | 158 | |
| 157 | function toPosixRelativePath(input: string): string { | |
| 158 | return input.replaceAll("\\", "/"); | |
| 159 | } | |
| 160 | ||
| 161 | function 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 | ||
| 199 | 159 | function stripRequestTag<T extends { _tag: string }>(body: T) { |
| 200 | 160 | return Struct.omit(body, ["_tag"]); |
| 201 | 161 | } |
| ... | ... | @@ -218,6 +178,10 @@ export type ServerRuntimeServices = |
| 218 | 178 | | TerminalManager |
| 219 | 179 | | Keybindings |
| 220 | 180 | | ServerSettingsService |
| 181 | | ProjectFaviconResolver | |
| 182 | | WorkspaceEntries | |
| 183 | | WorkspaceFileSystem | |
| 184 | | WorkspacePaths | |
| 221 | 185 | | Open |
| 222 | 186 | | AnalyticsService; |
| 223 | 187 | |
| ... | ... | @@ -263,6 +227,9 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< |
| 263 | 227 | const serverSettingsManager = yield* ServerSettingsService; |
| 264 | 228 | const providerRegistry = yield* ProviderRegistry; |
| 265 | 229 | const git = yield* GitCore; |
| 230 | const workspaceEntries = yield* WorkspaceEntries; | |
| 231 | const workspaceFileSystem = yield* WorkspaceFileSystem; | |
| 232 | const workspacePaths = yield* WorkspacePaths; | |
| 266 | 233 | const fileSystem = yield* FileSystem.FileSystem; |
| 267 | 234 | const path = yield* Path.Path; |
| 268 | 235 | |
| ... | ... | @@ -312,35 +279,21 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< |
| 312 | 279 | const normalizeDispatchCommand = Effect.fnUntraced(function* (input: { |
| 313 | 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 | 282 | if (input.command.type === "project.create") { |
| 334 | 283 | return { |
| 335 | 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 | 288 | } satisfies OrchestrationCommand; |
| 338 | 289 | } |
| 339 | 290 | |
| 340 | 291 | if (input.command.type === "project.meta.update" && input.command.workspaceRoot !== undefined) { |
| 341 | 292 | return { |
| 342 | 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 | 297 | } satisfies OrchestrationCommand; |
| 345 | 298 | } |
| 346 | 299 | |
| ... | ... | @@ -437,7 +390,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< |
| 437 | 390 | void runPromise( |
| 438 | 391 | Effect.gen(function* () { |
| 439 | 392 | const url = new URL(req.url ?? "/", `http://localhost:${port}`); |
| 440 | if (tryHandleProjectFaviconRequest(url, res)) { | |
| 393 | if (yield* tryHandleProjectFaviconRequest(url, res)) { | |
| 441 | 394 | return; |
| 442 | 395 | } |
| 443 | 396 | |
| ... | ... | @@ -768,41 +721,26 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< |
| 768 | 721 | |
| 769 | 722 | case WS_METHODS.projectsSearchEntries: { |
| 770 | 723 | const body = stripRequestTag(request.body); |
| 771 | return yield* Effect.tryPromise({ | |
| 772 | try: () => searchWorkspaceEntries(body), | |
| 773 | catch: (cause) => | |
| 774 | new RouteRequestError({ | |
| 775 | message: `Failed to search workspace entries: ${String(cause)}`, | |
| 776 | }), | |
| 777 | }); | |
| 724 | return yield* workspaceEntries.search(body).pipe( | |
| 725 | Effect.mapError( | |
| 726 | (cause) => | |
| 727 | new RouteRequestError({ | |
| 728 | message: `Failed to search workspace entries: ${cause.detail}`, | |
| 729 | }), | |
| 730 | ), | |
| 731 | ); | |
| 778 | 732 | } |
| 779 | 733 | |
| 780 | 734 | case WS_METHODS.projectsWriteFile: { |
| 781 | 735 | const body = stripRequestTag(request.body); |
| 782 | const target = yield* resolveWorkspaceWritePath({ | |
| 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( | |
| 736 | return yield* workspaceFileSystem.writeFile(body).pipe( | |
| 798 | 737 | Effect.mapError( |
| 799 | 738 | (cause) => |
| 800 | 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 | } |
| 807 | 745 | |
| 808 | 746 | case WS_METHODS.shellOpenInEditor: { |