| 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,6 +68,7 @@ import { |
| 68 | type TestProviderAdapterHarness, | 68 | type TestProviderAdapterHarness, |
| 69 | } from "./TestProviderAdapter.integration.ts"; | 69 | } from "./TestProviderAdapter.integration.ts"; |
| 70 | import { deriveServerPaths, ServerConfig } from "../src/config.ts"; | 70 | import { deriveServerPaths, ServerConfig } from "../src/config.ts"; |
| 71 | import { WorkspaceEntriesLive } from "../src/workspace/Layers/WorkspaceEntries.ts"; | ||
| 71 | 72 | ||
| 72 | function runGit(cwd: string, args: ReadonlyArray<string>) { | 73 | function runGit(cwd: string, args: ReadonlyArray<string>) { |
| 73 | return execFileSync("git", args, { | 74 | return execFileSync("git", args, { |
| ... | @@ -317,6 +318,12 @@ export const makeOrchestrationIntegrationHarness = ( | ... | @@ -317,6 +318,12 @@ export const makeOrchestrationIntegrationHarness = ( |
| 317 | ); | 318 | ); |
| 318 | const checkpointReactorLayer = CheckpointReactorLive.pipe( | 319 | const checkpointReactorLayer = CheckpointReactorLive.pipe( |
| 319 | Layer.provideMerge(runtimeServicesLayer), | 320 | Layer.provideMerge(runtimeServicesLayer), |
| 321 | Layer.provideMerge( | ||
| 322 | WorkspaceEntriesLive.pipe( | ||
| 323 | Layer.provideMerge(gitCoreLayer), | ||
| 324 | Layer.provide(NodeServices.layer), | ||
| 325 | ), | ||
| 326 | ), | ||
| 320 | ); | 327 | ); |
| 321 | const orchestrationReactorLayer = OrchestrationReactorLive.pipe( | 328 | const orchestrationReactorLayer = OrchestrationReactorLive.pipe( |
| 322 | Layer.provideMerge(runtimeIngestionLayer), | 329 | Layer.provideMerge(runtimeIngestionLayer), |
apps/server/src/git/Layers/GitCore.test.ts+82-8| ... | @@ -138,6 +138,13 @@ function buildLargeText(lineCount = 20_000): string { | ... | @@ -138,6 +138,13 @@ function buildLargeText(lineCount = 20_000): string { |
| 138 | .concat("\n"); | 138 | .concat("\n"); |
| 139 | } | 139 | } |
| 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 | // ── Tests ── | 148 | // ── Tests ── |
| 142 | 149 | ||
| 143 | it.layer(TestLayer)("git integration", (it) => { | 150 | it.layer(TestLayer)("git integration", (it) => { |
| ... | @@ -181,6 +188,55 @@ it.layer(TestLayer)("git integration", (it) => { | ... | @@ -181,6 +188,55 @@ it.layer(TestLayer)("git integration", (it) => { |
| 181 | ); | 188 | ); |
| 182 | }); | 189 | }); |
| 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 | // ── listGitBranches ── | 240 | // ── listGitBranches ── |
| 185 | 241 | ||
| 186 | describe("listGitBranches", () => { | 242 | describe("listGitBranches", () => { |
| ... | @@ -457,12 +513,18 @@ it.layer(TestLayer)("git integration", (it) => { | ... | @@ -457,12 +513,18 @@ it.layer(TestLayer)("git integration", (it) => { |
| 457 | yield* (yield* GitCore).checkoutBranch({ cwd: source, branch: featureBranch }); | 513 | yield* (yield* GitCore).checkoutBranch({ cwd: source, branch: featureBranch }); |
| 458 | const core = yield* GitCore; | 514 | const core = yield* GitCore; |
| 459 | yield* Effect.promise(() => | 515 | yield* Effect.promise(() => |
| 460 | vi.waitFor(async () => { | 516 | vi.waitFor( |
| 461 | const details = await runPromise(core.statusDetails(source)); | 517 | async () => { |
| 462 | expect(details.branch).toBe(featureBranch); | 518 | const details = await runPromise(core.statusDetails(source)); |
| 463 | expect(details.aheadCount).toBe(0); | 519 | expect(details.branch).toBe(featureBranch); |
| 464 | expect(details.behindCount).toBe(1); | 520 | expect(details.aheadCount).toBe(0); |
| 465 | }), | 521 | expect(details.behindCount).toBe(1); |
| 522 | }, | ||
| 523 | { | ||
| 524 | timeout: 10_000, | ||
| 525 | interval: 100, | ||
| 526 | }, | ||
| 527 | ), | ||
| 466 | ); | 528 | ); |
| 467 | }), | 529 | }), |
| 468 | ); | 530 | ); |
| ... | @@ -541,7 +603,13 @@ it.layer(TestLayer)("git integration", (it) => { | ... | @@ -541,7 +603,13 @@ it.layer(TestLayer)("git integration", (it) => { |
| 541 | const core = yield* makeIsolatedGitCore((input) => { | 603 | const core = yield* makeIsolatedGitCore((input) => { |
| 542 | if (input.args[0] === "fetch") { | 604 | if (input.args[0] === "fetch") { |
| 543 | fetchArgs = [...input.args]; | 605 | fetchArgs = [...input.args]; |
| 544 | return Effect.succeed({ code: 0, stdout: "", stderr: "" }); | 606 | return Effect.succeed({ |
| 607 | code: 0, | ||
| 608 | stdout: "", | ||
| 609 | stderr: "", | ||
| 610 | stdoutTruncated: false, | ||
| 611 | stderrTruncated: false, | ||
| 612 | }); | ||
| 545 | } | 613 | } |
| 546 | return realGitCore.execute(input); | 614 | return realGitCore.execute(input); |
| 547 | }); | 615 | }); |
| ... | @@ -594,7 +662,13 @@ it.layer(TestLayer)("git integration", (it) => { | ... | @@ -594,7 +662,13 @@ it.layer(TestLayer)("git integration", (it) => { |
| 594 | if (input.args[0] === "fetch") { | 662 | if (input.args[0] === "fetch") { |
| 595 | fetchStarted = true; | 663 | fetchStarted = true; |
| 596 | return Effect.promise(() => | 664 | return Effect.promise(() => |
| 597 | waitForReleasePromise.then(() => ({ code: 0, stdout: "", stderr: "" })), | 665 | waitForReleasePromise.then(() => ({ |
| 666 | code: 0, | ||
| 667 | stdout: "", | ||
| 668 | stderr: "", | ||
| 669 | stdoutTruncated: false, | ||
| 670 | stderrTruncated: false, | ||
| 671 | })), | ||
| 598 | ); | 672 | ); |
| 599 | } | 673 | } |
| 600 | return realGitCore.execute(input); | 674 | return realGitCore.execute(input); |
apps/server/src/git/Layers/GitCore.ts+153-7| ... | @@ -37,6 +37,8 @@ const PREPARED_COMMIT_PATCH_MAX_OUTPUT_BYTES = 49_000; | ... | @@ -37,6 +37,8 @@ const PREPARED_COMMIT_PATCH_MAX_OUTPUT_BYTES = 49_000; |
| 37 | const RANGE_COMMIT_SUMMARY_MAX_OUTPUT_BYTES = 19_000; | 37 | const RANGE_COMMIT_SUMMARY_MAX_OUTPUT_BYTES = 19_000; |
| 38 | const RANGE_DIFF_SUMMARY_MAX_OUTPUT_BYTES = 19_000; | 38 | const RANGE_DIFF_SUMMARY_MAX_OUTPUT_BYTES = 19_000; |
| 39 | const RANGE_DIFF_PATCH_MAX_OUTPUT_BYTES = 59_000; | 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 | const STATUS_UPSTREAM_REFRESH_INTERVAL = Duration.seconds(15); | 42 | const STATUS_UPSTREAM_REFRESH_INTERVAL = Duration.seconds(15); |
| 41 | const STATUS_UPSTREAM_REFRESH_TIMEOUT = Duration.seconds(5); | 43 | const STATUS_UPSTREAM_REFRESH_TIMEOUT = Duration.seconds(5); |
| 42 | const STATUS_UPSTREAM_REFRESH_CACHE_CAPACITY = 2_048; | 44 | const STATUS_UPSTREAM_REFRESH_CACHE_CAPACITY = 2_048; |
| ... | @@ -55,6 +57,7 @@ class StatusUpstreamRefreshCacheKey extends Data.Class<{ | ... | @@ -55,6 +57,7 @@ class StatusUpstreamRefreshCacheKey extends Data.Class<{ |
| 55 | }> {} | 57 | }> {} |
| 56 | 58 | ||
| 57 | interface ExecuteGitOptions { | 59 | interface ExecuteGitOptions { |
| 60 | stdin?: string | undefined; | ||
| 58 | timeoutMs?: number | undefined; | 61 | timeoutMs?: number | undefined; |
| 59 | allowNonZeroExit?: boolean | undefined; | 62 | allowNonZeroExit?: boolean | undefined; |
| 60 | fallbackErrorMessage?: string | undefined; | 63 | fallbackErrorMessage?: string | undefined; |
| ... | @@ -96,6 +99,47 @@ function parseNumstatEntries( | ... | @@ -96,6 +99,47 @@ function parseNumstatEntries( |
| 96 | return entries; | 99 | return entries; |
| 97 | } | 100 | } |
| 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 | function parsePorcelainPath(line: string): string | null { | 143 | function parsePorcelainPath(line: string): string | null { |
| 100 | if (line.startsWith("? ") || line.startsWith("! ")) { | 144 | if (line.startsWith("? ") || line.startsWith("! ")) { |
| 101 | const simple = line.slice(2).trim(); | 145 | const simple = line.slice(2).trim(); |
| ... | @@ -445,7 +489,7 @@ const collectOutput = Effect.fn("collectOutput")(function* <E>( | ... | @@ -445,7 +489,7 @@ const collectOutput = Effect.fn("collectOutput")(function* <E>( |
| 445 | maxOutputBytes: number, | 489 | maxOutputBytes: number, |
| 446 | truncateOutputAtMaxBytes: boolean, | 490 | truncateOutputAtMaxBytes: boolean, |
| 447 | onLine: ((line: string) => Effect.Effect<void, never>) | undefined, | 491 | onLine: ((line: string) => Effect.Effect<void, never>) | undefined, |
| 448 | ): Effect.fn.Return<string, GitCommandError> { | 492 | ): Effect.fn.Return<{ readonly text: string; readonly truncated: boolean }, GitCommandError> { |
| 449 | const decoder = new TextDecoder(); | 493 | const decoder = new TextDecoder(); |
| 450 | let bytes = 0; | 494 | let bytes = 0; |
| 451 | let text = ""; | 495 | let text = ""; |
| ... | @@ -507,7 +551,10 @@ const collectOutput = Effect.fn("collectOutput")(function* <E>( | ... | @@ -507,7 +551,10 @@ const collectOutput = Effect.fn("collectOutput")(function* <E>( |
| 507 | text += remainder; | 551 | text += remainder; |
| 508 | lineBuffer += remainder; | 552 | lineBuffer += remainder; |
| 509 | yield* emitCompleteLines(true); | 553 | yield* emitCompleteLines(true); |
| 510 | return truncated ? `${text}${OUTPUT_TRUNCATED_MARKER}` : text; | 554 | return { |
| 555 | text, | ||
| 556 | truncated, | ||
| 557 | }; | ||
| 511 | }); | 558 | }); |
| 512 | 559 | ||
| 513 | export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { | 560 | export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { |
| ... | @@ -571,13 +618,18 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { | ... | @@ -571,13 +618,18 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { |
| 571 | Effect.map((value) => Number(value)), | 618 | Effect.map((value) => Number(value)), |
| 572 | Effect.mapError(toGitCommandError(commandInput, "failed to report exit code.")), | 619 | Effect.mapError(toGitCommandError(commandInput, "failed to report exit code.")), |
| 573 | ), | 620 | ), |
| 621 | input.stdin === undefined | ||
| 622 | ? Effect.void | ||
| 623 | : Stream.run(Stream.encodeText(Stream.make(input.stdin)), child.stdin).pipe( | ||
| 624 | Effect.mapError(toGitCommandError(commandInput, "failed to write stdin.")), | ||
| 625 | ), | ||
| 574 | ], | 626 | ], |
| 575 | { concurrency: "unbounded" }, | 627 | { concurrency: "unbounded" }, |
| 576 | ); | 628 | ).pipe(Effect.map(([stdout, stderr, exitCode]) => [stdout, stderr, exitCode] as const)); |
| 577 | yield* trace2Monitor.flush; | 629 | yield* trace2Monitor.flush; |
| 578 | 630 | ||
| 579 | if (!input.allowNonZeroExit && exitCode !== 0) { | 631 | if (!input.allowNonZeroExit && exitCode !== 0) { |
| 580 | const trimmedStderr = stderr.trim(); | 632 | const trimmedStderr = stderr.text.trim(); |
| 581 | return yield* new GitCommandError({ | 633 | return yield* new GitCommandError({ |
| 582 | operation: commandInput.operation, | 634 | operation: commandInput.operation, |
| 583 | command: quoteGitCommand(commandInput.args), | 635 | command: quoteGitCommand(commandInput.args), |
| ... | @@ -589,7 +641,13 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { | ... | @@ -589,7 +641,13 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { |
| 589 | }); | 641 | }); |
| 590 | } | 642 | } |
| 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 | return yield* runGitCommand().pipe( | 653 | return yield* runGitCommand().pipe( |
| ... | @@ -618,11 +676,12 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { | ... | @@ -618,11 +676,12 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { |
| 618 | cwd: string, | 676 | cwd: string, |
| 619 | args: readonly string[], | 677 | args: readonly string[], |
| 620 | options: ExecuteGitOptions = {}, | 678 | options: ExecuteGitOptions = {}, |
| 621 | ): Effect.Effect<{ code: number; stdout: string; stderr: string }, GitCommandError> => | 679 | ): Effect.Effect<ExecuteGitResult, GitCommandError> => |
| 622 | execute({ | 680 | execute({ |
| 623 | operation, | 681 | operation, |
| 624 | cwd, | 682 | cwd, |
| 625 | args, | 683 | args, |
| 684 | ...(options.stdin !== undefined ? { stdin: options.stdin } : {}), | ||
| 626 | allowNonZeroExit: true, | 685 | allowNonZeroExit: true, |
| 627 | ...(options.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}), | 686 | ...(options.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}), |
| 628 | ...(options.maxOutputBytes !== undefined ? { maxOutputBytes: options.maxOutputBytes } : {}), | 687 | ...(options.maxOutputBytes !== undefined ? { maxOutputBytes: options.maxOutputBytes } : {}), |
| ... | @@ -679,7 +738,11 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { | ... | @@ -679,7 +738,11 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { |
| 679 | args: readonly string[], | 738 | args: readonly string[], |
| 680 | options: ExecuteGitOptions = {}, | 739 | options: ExecuteGitOptions = {}, |
| 681 | ): Effect.Effect<string, GitCommandError> => | 740 | ): Effect.Effect<string, GitCommandError> => |
| 682 | executeGit(operation, cwd, args, options).pipe(Effect.map((result) => result.stdout)); | 741 | executeGit(operation, cwd, args, options).pipe( |
| 742 | Effect.map((result) => | ||
| 743 | result.stdoutTruncated ? `${result.stdout}${OUTPUT_TRUNCATED_MARKER}` : result.stdout, | ||
| 744 | ), | ||
| 745 | ); | ||
| 683 | 746 | ||
| 684 | const branchExists = (cwd: string, branch: string): Effect.Effect<boolean, GitCommandError> => | 747 | const branchExists = (cwd: string, branch: string): Effect.Effect<boolean, GitCommandError> => |
| 685 | executeGit( | 748 | executeGit( |
| ... | @@ -1416,6 +1479,86 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { | ... | @@ -1416,6 +1479,86 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { |
| 1416 | Effect.map((trimmed) => (trimmed.length > 0 ? trimmed : null)), | 1479 | Effect.map((trimmed) => (trimmed.length > 0 ? trimmed : null)), |
| 1417 | ); | 1480 | ); |
| 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 | const listBranches: GitCoreShape["listBranches"] = Effect.fn("listBranches")(function* (input) { | 1562 | const listBranches: GitCoreShape["listBranches"] = Effect.fn("listBranches")(function* (input) { |
| 1420 | const branchRecencyPromise = readBranchRecency(input.cwd).pipe( | 1563 | const branchRecencyPromise = readBranchRecency(input.cwd).pipe( |
| 1421 | Effect.catch(() => Effect.succeed(new Map<string, number>())), | 1564 | Effect.catch(() => Effect.succeed(new Map<string, number>())), |
| ... | @@ -1834,6 +1977,9 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { | ... | @@ -1834,6 +1977,9 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { |
| 1834 | pullCurrentBranch, | 1977 | pullCurrentBranch, |
| 1835 | readRangeContext, | 1978 | readRangeContext, |
| 1836 | readConfigValue, | 1979 | readConfigValue, |
| 1980 | isInsideWorkTree, | ||
| 1981 | listWorkspaceFiles, | ||
| 1982 | filterIgnoredPaths, | ||
| 1837 | listBranches, | 1983 | listBranches, |
| 1838 | createWorktree, | 1984 | createWorktree, |
| 1839 | fetchPullRequestBranch, | 1985 | fetchPullRequestBranch, |
apps/server/src/git/Services/GitCore.ts+28| ... | @@ -28,6 +28,7 @@ export interface ExecuteGitInput { | ... | @@ -28,6 +28,7 @@ export interface ExecuteGitInput { |
| 28 | readonly operation: string; | 28 | readonly operation: string; |
| 29 | readonly cwd: string; | 29 | readonly cwd: string; |
| 30 | readonly args: ReadonlyArray<string>; | 30 | readonly args: ReadonlyArray<string>; |
| 31 | readonly stdin?: string; | ||
| 31 | readonly env?: NodeJS.ProcessEnv; | 32 | readonly env?: NodeJS.ProcessEnv; |
| 32 | readonly allowNonZeroExit?: boolean; | 33 | readonly allowNonZeroExit?: boolean; |
| 33 | readonly timeoutMs?: number; | 34 | readonly timeoutMs?: number; |
| ... | @@ -40,6 +41,8 @@ export interface ExecuteGitResult { | ... | @@ -40,6 +41,8 @@ export interface ExecuteGitResult { |
| 40 | readonly code: number; | 41 | readonly code: number; |
| 41 | readonly stdout: string; | 42 | readonly stdout: string; |
| 42 | readonly stderr: string; | 43 | readonly stderr: string; |
| 44 | readonly stdoutTruncated: boolean; | ||
| 45 | readonly stderrTruncated: boolean; | ||
| 43 | } | 46 | } |
| 44 | 47 | ||
| 45 | export interface GitStatusDetails extends Omit<GitStatusResult, "pr"> { | 48 | export interface GitStatusDetails extends Omit<GitStatusResult, "pr"> { |
| ... | @@ -93,6 +96,11 @@ export interface GitRangeContext { | ... | @@ -93,6 +96,11 @@ export interface GitRangeContext { |
| 93 | diffPatch: string; | 96 | diffPatch: string; |
| 94 | } | 97 | } |
| 95 | 98 | ||
| 99 | export interface GitListWorkspaceFilesResult { | ||
| 100 | readonly paths: ReadonlyArray<string>; | ||
| 101 | readonly truncated: boolean; | ||
| 102 | } | ||
| 103 | |||
| 96 | export interface GitRenameBranchInput { | 104 | export interface GitRenameBranchInput { |
| 97 | cwd: string; | 105 | cwd: string; |
| 98 | oldBranch: string; | 106 | oldBranch: string; |
| ... | @@ -190,6 +198,26 @@ export interface GitCoreShape { | ... | @@ -190,6 +198,26 @@ export interface GitCoreShape { |
| 190 | key: string, | 198 | key: string, |
| 191 | ) => Effect.Effect<string | null, GitCommandError>; | 199 | ) => Effect.Effect<string | null, GitCommandError>; |
| 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 | * List local + remote branches and branch metadata. | 222 | * List local + remote branches and branch metadata. |
| 195 | */ | 223 | */ |
apps/server/src/orchestration/Layers/CheckpointReactor.test.ts+4-1| ... | @@ -38,6 +38,7 @@ import { | ... | @@ -38,6 +38,7 @@ import { |
| 38 | } from "../../provider/Services/ProviderService.ts"; | 38 | } from "../../provider/Services/ProviderService.ts"; |
| 39 | import { checkpointRefForThreadTurn } from "../../checkpointing/Utils.ts"; | 39 | import { checkpointRefForThreadTurn } from "../../checkpointing/Utils.ts"; |
| 40 | import { ServerConfig } from "../../config.ts"; | 40 | import { ServerConfig } from "../../config.ts"; |
| 41 | import { WorkspaceEntriesLive } from "../../workspace/Layers/WorkspaceEntries.ts"; | ||
| 41 | 42 | ||
| 42 | const asProjectId = (value: string): ProjectId => ProjectId.makeUnsafe(value); | 43 | const asProjectId = (value: string): ProjectId => ProjectId.makeUnsafe(value); |
| 43 | const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value); | 44 | const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value); |
| ... | @@ -261,7 +262,9 @@ describe("CheckpointReactor", () => { | ... | @@ -261,7 +262,9 @@ describe("CheckpointReactor", () => { |
| 261 | Layer.provideMerge(orchestrationLayer), | 262 | Layer.provideMerge(orchestrationLayer), |
| 262 | Layer.provideMerge(RuntimeReceiptBusLive), | 263 | Layer.provideMerge(RuntimeReceiptBusLive), |
| 263 | Layer.provideMerge(Layer.succeed(ProviderService, provider.service)), | 264 | Layer.provideMerge(Layer.succeed(ProviderService, provider.service)), |
| 264 | Layer.provideMerge(CheckpointStoreLive.pipe(Layer.provide(GitCoreLive))), | 265 | Layer.provideMerge(CheckpointStoreLive), |
| 266 | Layer.provideMerge(WorkspaceEntriesLive), | ||
| 267 | Layer.provideMerge(GitCoreLive), | ||
| 265 | Layer.provideMerge(ServerConfigLayer), | 268 | Layer.provideMerge(ServerConfigLayer), |
| 266 | Layer.provideMerge(NodeServices.layer), | 269 | Layer.provideMerge(NodeServices.layer), |
| 267 | ); | 270 | ); |
apps/server/src/orchestration/Layers/CheckpointReactor.ts+4-3| ... | @@ -16,7 +16,6 @@ import { | ... | @@ -16,7 +16,6 @@ import { |
| 16 | checkpointRefForThreadTurn, | 16 | checkpointRefForThreadTurn, |
| 17 | resolveThreadWorkspaceCwd, | 17 | resolveThreadWorkspaceCwd, |
| 18 | } from "../../checkpointing/Utils.ts"; | 18 | } from "../../checkpointing/Utils.ts"; |
| 19 | import { clearWorkspaceIndexCache } from "../../workspaceEntries.ts"; | ||
| 20 | import { CheckpointStore } from "../../checkpointing/Services/CheckpointStore.ts"; | 19 | import { CheckpointStore } from "../../checkpointing/Services/CheckpointStore.ts"; |
| 21 | import { ProviderService } from "../../provider/Services/ProviderService.ts"; | 20 | import { ProviderService } from "../../provider/Services/ProviderService.ts"; |
| 22 | import { CheckpointReactor, type CheckpointReactorShape } from "../Services/CheckpointReactor.ts"; | 21 | import { CheckpointReactor, type CheckpointReactorShape } from "../Services/CheckpointReactor.ts"; |
| ... | @@ -25,6 +24,7 @@ import { RuntimeReceiptBus } from "../Services/RuntimeReceiptBus.ts"; | ... | @@ -25,6 +24,7 @@ import { RuntimeReceiptBus } from "../Services/RuntimeReceiptBus.ts"; |
| 25 | import { CheckpointStoreError } from "../../checkpointing/Errors.ts"; | 24 | import { CheckpointStoreError } from "../../checkpointing/Errors.ts"; |
| 26 | import { OrchestrationDispatchError } from "../Errors.ts"; | 25 | import { OrchestrationDispatchError } from "../Errors.ts"; |
| 27 | import { isGitRepository } from "../../git/Utils.ts"; | 26 | import { isGitRepository } from "../../git/Utils.ts"; |
| 27 | import { WorkspaceEntries } from "../../workspace/Services/WorkspaceEntries.ts"; | ||
| 28 | 28 | ||
| 29 | type ReactorInput = | 29 | type ReactorInput = |
| 30 | | { | 30 | | { |
| ... | @@ -68,6 +68,7 @@ const make = Effect.gen(function* () { | ... | @@ -68,6 +68,7 @@ const make = Effect.gen(function* () { |
| 68 | const providerService = yield* ProviderService; | 68 | const providerService = yield* ProviderService; |
| 69 | const checkpointStore = yield* CheckpointStore; | 69 | const checkpointStore = yield* CheckpointStore; |
| 70 | const receiptBus = yield* RuntimeReceiptBus; | 70 | const receiptBus = yield* RuntimeReceiptBus; |
| 71 | const workspaceEntries = yield* WorkspaceEntries; | ||
| 71 | 72 | ||
| 72 | const appendRevertFailureActivity = (input: { | 73 | const appendRevertFailureActivity = (input: { |
| 73 | readonly threadId: ThreadId; | 74 | readonly threadId: ThreadId; |
| ... | @@ -226,7 +227,7 @@ const make = Effect.gen(function* () { | ... | @@ -226,7 +227,7 @@ const make = Effect.gen(function* () { |
| 226 | 227 | ||
| 227 | // Invalidate the workspace entry cache so the @-mention file picker | 228 | // Invalidate the workspace entry cache so the @-mention file picker |
| 228 | // reflects files created or deleted during this turn. | 229 | // reflects files created or deleted during this turn. |
| 229 | clearWorkspaceIndexCache(input.cwd); | 230 | yield* workspaceEntries.invalidate(input.cwd); |
| 230 | 231 | ||
| 231 | const files = yield* checkpointStore | 232 | const files = yield* checkpointStore |
| 232 | .diffCheckpoints({ | 233 | .diffCheckpoints({ |
| ... | @@ -642,7 +643,7 @@ const make = Effect.gen(function* () { | ... | @@ -642,7 +643,7 @@ const make = Effect.gen(function* () { |
| 642 | 643 | ||
| 643 | // Invalidate the workspace entry cache so the @-mention file picker | 644 | // Invalidate the workspace entry cache so the @-mention file picker |
| 644 | // reflects the reverted filesystem state. | 645 | // reflects the reverted filesystem state. |
| 645 | clearWorkspaceIndexCache(sessionRuntime.value.cwd); | 646 | yield* workspaceEntries.invalidate(sessionRuntime.value.cwd); |
| 646 | 647 | ||
| 647 | const rolledBackTurns = Math.max(0, currentTurnCount - event.payload.turnCount); | 648 | const rolledBackTurns = Math.max(0, currentTurnCount - event.payload.turnCount); |
| 648 | if (rolledBackTurns > 0) { | 649 | if (rolledBackTurns > 0) { |
apps/server/src/project/Layers/ProjectFaviconResolver.test.ts created+74| ... | @@ -0,0 +1,74 @@ | ||
| 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,7 +3,11 @@ import http from "node:http"; |
| 3 | import os from "node:os"; | 3 | import os from "node:os"; |
| 4 | import path from "node:path"; | 4 | import path from "node:path"; |
| 5 | 5 | ||
| 6 | import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| 7 | import { Effect } from "effect"; | ||
| 6 | import { afterEach, describe, expect, it } from "vitest"; | 8 | import { afterEach, describe, expect, it } from "vitest"; |
| 9 | |||
| 10 | import { ProjectFaviconResolverLive } from "./project/Layers/ProjectFaviconResolver.ts"; | ||
| 7 | import { tryHandleProjectFaviconRequest } from "./projectFaviconRoute"; | 11 | import { tryHandleProjectFaviconRequest } from "./projectFaviconRoute"; |
| 8 | 12 | ||
| 9 | interface HttpResponse { | 13 | interface HttpResponse { |
| ... | @@ -23,11 +27,25 @@ function makeTempDir(prefix: string): string { | ... | @@ -23,11 +27,25 @@ function makeTempDir(prefix: string): string { |
| 23 | async function withRouteServer(run: (baseUrl: string) => Promise<void>): Promise<void> { | 27 | async function withRouteServer(run: (baseUrl: string) => Promise<void>): Promise<void> { |
| 24 | const server = http.createServer((req, res) => { | 28 | const server = http.createServer((req, res) => { |
| 25 | const url = new URL(req.url ?? "/", "http://127.0.0.1"); | 29 | const url = new URL(req.url ?? "/", "http://127.0.0.1"); |
| 26 | if (tryHandleProjectFaviconRequest(url, res)) { | 30 | void Effect.runPromise( |
| 27 | return; | 31 | tryHandleProjectFaviconRequest(url, res).pipe( |
| 28 | } | 32 | Effect.provide(ProjectFaviconResolverLive), |
| 29 | res.writeHead(404, { "Content-Type": "text/plain" }); | 33 | Effect.provide(NodeServices.layer), |
| 30 | res.end("Not Found"); | 34 | Effect.flatMap((handled) => |
| 35 | handled | ||
| 36 | ? Effect.void | ||
| 37 | : Effect.sync(() => { | ||
| 38 | res.writeHead(404, { "Content-Type": "text/plain" }); | ||
| 39 | res.end("Not Found"); | ||
| 40 | }), | ||
| 41 | ), | ||
| 42 | ), | ||
| 43 | ).catch(() => { | ||
| 44 | if (!res.headersSent) { | ||
| 45 | res.writeHead(500, { "Content-Type": "text/plain" }); | ||
| 46 | res.end("Internal Server Error"); | ||
| 47 | } | ||
| 48 | }); | ||
| 31 | }); | 49 | }); |
| 32 | 50 | ||
| 33 | await new Promise<void>((resolve, reject) => { | 51 | await new Promise<void>((resolve, reject) => { |
apps/server/src/projectFaviconRoute.ts+56-151| ... | @@ -1,6 +1,8 @@ | ... | @@ -1,6 +1,8 @@ |
| 1 | import fs from "node:fs"; | ||
| 2 | import http from "node:http"; | 1 | import http from "node:http"; |
| 3 | import path from "node:path"; | 2 | import path from "node:path"; |
| 3 | import { Effect, FileSystem } from "effect"; | ||
| 4 | |||
| 5 | import { ProjectFaviconResolver } from "./project/Services/ProjectFaviconResolver.ts"; | ||
| 4 | 6 | ||
| 5 | const FAVICON_MIME_TYPES: Record<string, string> = { | 7 | const FAVICON_MIME_TYPES: Record<string, string> = { |
| 6 | ".png": "image/png", | 8 | ".png": "image/png", |
| ... | @@ -11,161 +13,64 @@ const FAVICON_MIME_TYPES: Record<string, string> = { | ... | @@ -11,161 +13,64 @@ const FAVICON_MIME_TYPES: Record<string, string> = { |
| 11 | 13 | ||
| 12 | 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>`; | 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. | 16 | export const tryHandleProjectFaviconRequest = Effect.fn("tryHandleProjectFaviconRequest")( |
| 15 | const FAVICON_CANDIDATES = [ | 17 | function* ( |
| 16 | "favicon.svg", | 18 | url: URL, |
| 17 | "favicon.ico", | 19 | res: http.ServerResponse, |
| 18 | "favicon.png", | 20 | ): Effect.fn.Return<boolean, never, ProjectFaviconResolver | FileSystem.FileSystem> { |
| 19 | "public/favicon.svg", | 21 | const respond = ( |
| 20 | "public/favicon.ico", | 22 | statusCode: number, |
| 21 | "public/favicon.png", | 23 | headers: Record<string, string>, |
| 22 | "app/favicon.ico", | 24 | body: string | Uint8Array, |
| 23 | "app/favicon.png", | 25 | ) => { |
| 24 | "app/icon.svg", | 26 | res.writeHead(statusCode, headers); |
| 25 | "app/icon.png", | 27 | res.end(body); |
| 26 | "app/icon.ico", | 28 | }; |
| 27 | "src/favicon.ico", | 29 | |
| 28 | "src/favicon.svg", | 30 | if (url.pathname !== "/api/project-favicon") { |
| 29 | "src/app/favicon.ico", | 31 | return false; |
| 30 | "src/app/icon.svg", | ||
| 31 | "src/app/icon.png", | ||
| 32 | "assets/icon.svg", | ||
| 33 | "assets/icon.png", | ||
| 34 | "assets/logo.svg", | ||
| 35 | "assets/logo.png", | ||
| 36 | ]; | ||
| 37 | |||
| 38 | // Files that may contain a <link rel="icon"> or icon metadata declaration. | ||
| 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; | ||
| 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 { | 34 | const projectCwd = url.searchParams.get("cwd"); |
| 91 | res.writeHead(200, { | 35 | if (!projectCwd) { |
| 92 | "Content-Type": "image/svg+xml", | 36 | respond(400, { "Content-Type": "text/plain" }, "Missing cwd parameter"); |
| 93 | "Cache-Control": "public, max-age=3600", | 37 | return true; |
| 94 | }); | ||
| 95 | res.end(FALLBACK_FAVICON_SVG); | ||
| 96 | } | ||
| 97 | |||
| 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; | ||
| 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 => { | 40 | const fileSystem = yield* FileSystem.FileSystem; |
| 130 | if (index >= ICON_SOURCE_FILES.length) { | 41 | const faviconResolver = yield* ProjectFaviconResolver; |
| 131 | serveFallbackFavicon(res); | 42 | const resolvedPath = yield* faviconResolver.resolvePath(projectCwd); |
| 132 | return; | 43 | |
| 44 | if (!resolvedPath) { | ||
| 45 | respond( | ||
| 46 | 200, | ||
| 47 | { | ||
| 48 | "Content-Type": "image/svg+xml", | ||
| 49 | "Cache-Control": "public, max-age=3600", | ||
| 50 | }, | ||
| 51 | FALLBACK_FAVICON_SVG, | ||
| 52 | ); | ||
| 53 | return true; | ||
| 133 | } | 54 | } |
| 134 | const sourceFile = path.join(projectCwd, ICON_SOURCE_FILES[index]!); | ||
| 135 | fs.readFile(sourceFile, "utf8", (err, content) => { | ||
| 136 | if (err) { | ||
| 137 | trySourceFiles(index + 1); | ||
| 138 | return; | ||
| 139 | } | ||
| 140 | const href = extractIconHref(content); | ||
| 141 | if (!href) { | ||
| 142 | trySourceFiles(index + 1); | ||
| 143 | return; | ||
| 144 | } | ||
| 145 | const candidates = resolveIconHref(projectCwd, href); | ||
| 146 | tryResolvedPaths(candidates, 0, () => trySourceFiles(index + 1)); | ||
| 147 | }); | ||
| 148 | }; | ||
| 149 | 55 | ||
| 150 | const tryCandidates = (index: number): void => { | 56 | const data = yield* fileSystem |
| 151 | if (index >= FAVICON_CANDIDATES.length) { | 57 | .readFile(resolvedPath) |
| 152 | trySourceFiles(0); | 58 | .pipe(Effect.catch(() => Effect.succeed(null))); |
| 153 | return; | 59 | if (!data) { |
| 154 | } | 60 | respond(500, { "Content-Type": "text/plain" }, "Read error"); |
| 155 | const candidate = path.join(projectCwd, FAVICON_CANDIDATES[index]!); | 61 | return true; |
| 156 | if (!isPathWithinProject(projectCwd, candidate)) { | ||
| 157 | tryCandidates(index + 1); | ||
| 158 | return; | ||
| 159 | } | 62 | } |
| 160 | fs.stat(candidate, (err, stats) => { | ||
| 161 | if (err || !stats?.isFile()) { | ||
| 162 | tryCandidates(index + 1); | ||
| 163 | return; | ||
| 164 | } | ||
| 165 | serveFaviconFile(candidate, res); | ||
| 166 | }); | ||
| 167 | }; | ||
| 168 | 63 | ||
| 169 | tryCandidates(0); | 64 | const ext = path.extname(resolvedPath).toLowerCase(); |
| 170 | return true; | 65 | const contentType = FAVICON_MIME_TYPES[ext] ?? "application/octet-stream"; |
| 171 | } | 66 | respond( |
| 67 | 200, | ||
| 68 | { | ||
| 69 | "Content-Type": contentType, | ||
| 70 | "Cache-Control": "public, max-age=3600", | ||
| 71 | }, | ||
| 72 | data, | ||
| 73 | ); | ||
| 74 | return true; | ||
| 75 | }, | ||
| 76 | ); |
apps/server/src/serverLayers.ts+20-5| ... | @@ -34,6 +34,10 @@ import { GitHubCliLive } from "./git/Layers/GitHubCli"; | ... | @@ -34,6 +34,10 @@ import { GitHubCliLive } from "./git/Layers/GitHubCli"; |
| 34 | import { RoutingTextGenerationLive } from "./git/Layers/RoutingTextGeneration"; | 34 | import { RoutingTextGenerationLive } from "./git/Layers/RoutingTextGeneration"; |
| 35 | import { PtyAdapter } from "./terminal/Services/PTY"; | 35 | import { PtyAdapter } from "./terminal/Services/PTY"; |
| 36 | import { AnalyticsService } from "./telemetry/Services/AnalyticsService"; | 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 | type RuntimePtyAdapterLoader = { | 42 | type RuntimePtyAdapterLoader = { |
| 39 | layer: Layer.Layer<PtyAdapter, never, FileSystem.FileSystem | Path.Path>; | 43 | layer: Layer.Layer<PtyAdapter, never, FileSystem.FileSystem | Path.Path>; |
| ... | @@ -91,7 +95,8 @@ export function makeServerProviderLayer(): Layer.Layer< | ... | @@ -91,7 +95,8 @@ export function makeServerProviderLayer(): Layer.Layer< |
| 91 | 95 | ||
| 92 | export function makeServerRuntimeServicesLayer() { | 96 | export function makeServerRuntimeServicesLayer() { |
| 93 | const textGenerationLayer = RoutingTextGenerationLive; | 97 | const textGenerationLayer = RoutingTextGenerationLive; |
| 94 | const checkpointStoreLayer = CheckpointStoreLive.pipe(Layer.provide(GitCoreLive)); | 98 | const gitCoreLayer = GitCoreLive; |
| 99 | const checkpointStoreLayer = CheckpointStoreLive; | ||
| 95 | 100 | ||
| 96 | const orchestrationLayer = OrchestrationEngineLive.pipe( | 101 | const orchestrationLayer = OrchestrationEngineLive.pipe( |
| 97 | Layer.provide(OrchestrationProjectionPipelineLive), | 102 | Layer.provide(OrchestrationProjectionPipelineLive), |
| ... | @@ -116,11 +121,11 @@ export function makeServerRuntimeServicesLayer() { | ... | @@ -116,11 +121,11 @@ export function makeServerRuntimeServicesLayer() { |
| 116 | ); | 121 | ); |
| 117 | const providerCommandReactorLayer = ProviderCommandReactorLive.pipe( | 122 | const providerCommandReactorLayer = ProviderCommandReactorLive.pipe( |
| 118 | Layer.provideMerge(runtimeServicesLayer), | 123 | Layer.provideMerge(runtimeServicesLayer), |
| 119 | Layer.provideMerge(GitCoreLive), | ||
| 120 | Layer.provideMerge(textGenerationLayer), | 124 | Layer.provideMerge(textGenerationLayer), |
| 121 | ); | 125 | ); |
| 122 | const checkpointReactorLayer = CheckpointReactorLive.pipe( | 126 | const checkpointReactorLayer = CheckpointReactorLive.pipe( |
| 123 | Layer.provideMerge(runtimeServicesLayer), | 127 | Layer.provideMerge(runtimeServicesLayer), |
| 128 | Layer.provideMerge(WorkspaceEntriesLive), | ||
| 124 | ); | 129 | ); |
| 125 | const orchestrationReactorLayer = OrchestrationReactorLive.pipe( | 130 | const orchestrationReactorLayer = OrchestrationReactorLive.pipe( |
| 126 | Layer.provideMerge(runtimeIngestionLayer), | 131 | Layer.provideMerge(runtimeIngestionLayer), |
| ... | @@ -131,16 +136,26 @@ export function makeServerRuntimeServicesLayer() { | ... | @@ -131,16 +136,26 @@ export function makeServerRuntimeServicesLayer() { |
| 131 | const terminalLayer = TerminalManagerLive.pipe(Layer.provide(makeRuntimePtyAdapterLayer())); | 136 | const terminalLayer = TerminalManagerLive.pipe(Layer.provide(makeRuntimePtyAdapterLayer())); |
| 132 | 137 | ||
| 133 | const gitManagerLayer = GitManagerLive.pipe( | 138 | const gitManagerLayer = GitManagerLive.pipe( |
| 134 | Layer.provideMerge(GitCoreLive), | ||
| 135 | Layer.provideMerge(GitHubCliLive), | 139 | Layer.provideMerge(GitHubCliLive), |
| 136 | Layer.provideMerge(textGenerationLayer), | 140 | Layer.provideMerge(textGenerationLayer), |
| 137 | ); | 141 | ); |
| 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 | return Layer.mergeAll( | 151 | return Layer.mergeAll( |
| 140 | orchestrationReactorLayer, | 152 | orchestrationReactorLayer, |
| 141 | GitCoreLive, | 153 | workspacePathsLayer, |
| 154 | workspaceEntriesLayer, | ||
| 155 | workspaceFileSystemLayer, | ||
| 156 | projectFaviconResolverLayer, | ||
| 142 | gitManagerLayer, | 157 | gitManagerLayer, |
| 143 | terminalLayer, | 158 | terminalLayer, |
| 144 | KeybindingsLive, | 159 | KeybindingsLive, |
| 145 | ).pipe(Layer.provideMerge(NodeServices.layer)); | 160 | ).pipe(Layer.provideMerge(gitCoreLayer), Layer.provideMerge(NodeServices.layer)); |
| 146 | } | 161 | } |
apps/server/src/workspace/Layers/WorkspaceEntries.test.ts created+264| ... | @@ -0,0 +1,264 @@ | ||
| 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,7 +4,17 @@ import os from "node:os"; |
| 4 | import path from "node:path"; | 4 | import path from "node:path"; |
| 5 | 5 | ||
| 6 | import * as NodeServices from "@effect/platform-node/NodeServices"; | 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 | import { describe, expect, it, afterEach, vi } from "vitest"; | 18 | import { describe, expect, it, afterEach, vi } from "vitest"; |
| 9 | import { createServer } from "./wsServer"; | 19 | import { createServer } from "./wsServer"; |
| 10 | import WebSocket from "ws"; | 20 | import WebSocket from "ws"; |
| ... | @@ -475,6 +485,7 @@ function deriveServerPathsSync(baseDir: string, devUrl: URL | undefined) { | ... | @@ -475,6 +485,7 @@ function deriveServerPathsSync(baseDir: string, devUrl: URL | undefined) { |
| 475 | describe("WebSocket Server", () => { | 485 | describe("WebSocket Server", () => { |
| 476 | let server: Http.Server | null = null; | 486 | let server: Http.Server | null = null; |
| 477 | let serverScope: Scope.Closeable | null = null; | 487 | let serverScope: Scope.Closeable | null = null; |
| 488 | let disposeServerRuntime: (() => Promise<void>) | null = null; | ||
| 478 | const connections: WebSocket[] = []; | 489 | const connections: WebSocket[] = []; |
| 479 | const tempDirs: string[] = []; | 490 | const tempDirs: string[] = []; |
| 480 | 491 | ||
| ... | @@ -521,6 +532,12 @@ describe("WebSocket Server", () => { | ... | @@ -521,6 +532,12 @@ describe("WebSocket Server", () => { |
| 521 | options.providerRegistry ?? defaultProviderRegistryService, | 532 | options.providerRegistry ?? defaultProviderRegistryService, |
| 522 | ); | 533 | ); |
| 523 | const openLayer = Layer.succeed(Open, options.open ?? defaultOpenService); | 534 | const openLayer = Layer.succeed(Open, options.open ?? defaultOpenService); |
| 535 | const nodeServicesLayer = NodeServices.layer; | ||
| 536 | const serverSettingsLayer = ServerSettingsService.layerTest(options.serverSettings); | ||
| 537 | const serverSettingsRuntimeLayer = serverSettingsLayer.pipe( | ||
| 538 | Layer.provideMerge(nodeServicesLayer), | ||
| 539 | ); | ||
| 540 | const analyticsLayer = AnalyticsService.layerTest; | ||
| 524 | const serverConfigLayer = Layer.succeed(ServerConfig, { | 541 | const serverConfigLayer = Layer.succeed(ServerConfig, { |
| 525 | mode: "web", | 542 | mode: "web", |
| 526 | port: 0, | 543 | port: 0, |
| ... | @@ -536,6 +553,11 @@ describe("WebSocket Server", () => { | ... | @@ -536,6 +553,11 @@ describe("WebSocket Server", () => { |
| 536 | logWebSocketEvents: options.logWebSocketEvents ?? Boolean(options.devUrl), | 553 | logWebSocketEvents: options.logWebSocketEvents ?? Boolean(options.devUrl), |
| 537 | } satisfies ServerConfigShape); | 554 | } satisfies ServerConfigShape); |
| 538 | const infrastructureLayer = providerLayer.pipe(Layer.provideMerge(persistenceLayer)); | 555 | const infrastructureLayer = providerLayer.pipe(Layer.provideMerge(persistenceLayer)); |
| 556 | const providerRuntimeLayer = infrastructureLayer.pipe( | ||
| 557 | Layer.provideMerge(serverConfigLayer), | ||
| 558 | Layer.provideMerge(serverSettingsRuntimeLayer), | ||
| 559 | Layer.provideMerge(analyticsLayer), | ||
| 560 | ); | ||
| 539 | const runtimeOverrides = Layer.mergeAll( | 561 | const runtimeOverrides = Layer.mergeAll( |
| 540 | options.gitManager ? Layer.succeed(GitManager, options.gitManager) : Layer.empty, | 562 | options.gitManager ? Layer.succeed(GitManager, options.gitManager) : Layer.empty, |
| 541 | options.gitCore | 563 | options.gitCore |
| ... | @@ -548,41 +570,49 @@ describe("WebSocket Server", () => { | ... | @@ -548,41 +570,49 @@ describe("WebSocket Server", () => { |
| 548 | 570 | ||
| 549 | const runtimeLayer = Layer.merge( | 571 | const runtimeLayer = Layer.merge( |
| 550 | Layer.merge( | 572 | Layer.merge( |
| 551 | makeServerRuntimeServicesLayer().pipe(Layer.provide(infrastructureLayer)), | 573 | makeServerRuntimeServicesLayer().pipe( |
| 552 | infrastructureLayer, | 574 | Layer.provideMerge(providerRuntimeLayer), |
| 575 | Layer.provideMerge(serverConfigLayer), | ||
| 576 | Layer.provideMerge(serverSettingsRuntimeLayer), | ||
| 577 | Layer.provideMerge(analyticsLayer), | ||
| 578 | Layer.provideMerge(nodeServicesLayer), | ||
| 579 | ), | ||
| 580 | Layer.mergeAll(providerRuntimeLayer, serverSettingsRuntimeLayer, analyticsLayer), | ||
| 553 | ), | 581 | ), |
| 554 | runtimeOverrides, | 582 | runtimeOverrides, |
| 555 | ); | 583 | ); |
| 556 | const dependenciesLayer = Layer.empty.pipe( | 584 | const dependenciesLayer = Layer.mergeAll( |
| 557 | Layer.provideMerge(runtimeLayer), | 585 | runtimeLayer, |
| 558 | Layer.provideMerge(providerRegistryLayer), | 586 | providerRegistryLayer, |
| 559 | Layer.provideMerge(openLayer), | 587 | openLayer, |
| 560 | Layer.provideMerge(ServerSettingsService.layerTest(options.serverSettings)), | 588 | serverConfigLayer, |
| 561 | Layer.provideMerge(serverConfigLayer), | 589 | nodeServicesLayer, |
| 562 | Layer.provideMerge(AnalyticsService.layerTest), | ||
| 563 | Layer.provideMerge(NodeServices.layer), | ||
| 564 | ); | ||
| 565 | const runtimeServices = await Effect.runPromise( | ||
| 566 | Layer.build(dependenciesLayer).pipe(Scope.provide(scope)), | ||
| 567 | ); | 590 | ); |
| 568 | 591 | const runtime = ManagedRuntime.make(dependenciesLayer); | |
| 569 | try { | 592 | try { |
| 570 | const runtime = await Effect.runPromise( | 593 | const httpServer = await runtime.runPromise(createServer().pipe(Scope.provide(scope))); |
| 571 | createServer().pipe(Effect.provide(runtimeServices), Scope.provide(scope)), | 594 | disposeServerRuntime = () => runtime.dispose(); |
| 572 | ); | ||
| 573 | serverScope = scope; | 595 | serverScope = scope; |
| 574 | return runtime; | 596 | return httpServer; |
| 575 | } catch (error) { | 597 | } catch (error) { |
| 598 | await runtime.dispose(); | ||
| 576 | await Effect.runPromise(Scope.close(scope, Exit.void)); | 599 | await Effect.runPromise(Scope.close(scope, Exit.void)); |
| 577 | throw error; | 600 | throw error; |
| 578 | } | 601 | } |
| 579 | } | 602 | } |
| 580 | 603 | ||
| 581 | async function closeTestServer() { | 604 | async function closeTestServer() { |
| 582 | if (!serverScope) return; | 605 | if (!serverScope && !disposeServerRuntime) return; |
| 583 | const scope = serverScope; | 606 | const scope = serverScope; |
| 607 | const disposeRuntime = disposeServerRuntime; | ||
| 584 | serverScope = null; | 608 | serverScope = null; |
| 585 | await Effect.runPromise(Scope.close(scope, Exit.void)); | 609 | disposeServerRuntime = null; |
| 610 | if (scope) { | ||
| 611 | await Effect.runPromise(Scope.close(scope, Exit.void)); | ||
| 612 | } | ||
| 613 | if (disposeRuntime) { | ||
| 614 | await disposeRuntime(); | ||
| 615 | } | ||
| 586 | } | 616 | } |
| 587 | 617 | ||
| 588 | afterEach(async () => { | 618 | afterEach(async () => { |
| ... | @@ -1255,6 +1285,32 @@ describe("WebSocket Server", () => { | ... | @@ -1255,6 +1285,32 @@ describe("WebSocket Server", () => { |
| 1255 | expect(response.error?.message).toContain("exceeds current turn count"); | 1285 | expect(response.error?.message).toContain("exceeds current turn count"); |
| 1256 | }); | 1286 | }); |
| 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 | it("keeps orchestration domain push behavior for provider runtime events", async () => { | 1314 | it("keeps orchestration domain push behavior for provider runtime events", async () => { |
| 1259 | const runtimeEventPubSub = Effect.runSync(PubSub.unbounded<ProviderRuntimeEvent>()); | 1315 | const runtimeEventPubSub = Effect.runSync(PubSub.unbounded<ProviderRuntimeEvent>()); |
| 1260 | const emitRuntimeEvent = (event: ProviderRuntimeEvent) => { | 1316 | const emitRuntimeEvent = (event: ProviderRuntimeEvent) => { |
| ... | @@ -1665,6 +1721,50 @@ describe("WebSocket Server", () => { | ... | @@ -1665,6 +1721,50 @@ describe("WebSocket Server", () => { |
| 1665 | ); | 1721 | ); |
| 1666 | }); | 1722 | }); |
| 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 | it("rejects projects.writeFile paths outside the workspace root", async () => { | 1768 | it("rejects projects.writeFile paths outside the workspace root", async () => { |
| 1669 | const workspace = makeTempDir("t3code-ws-write-file-reject-"); | 1769 | const workspace = makeTempDir("t3code-ws-write-file-reject-"); |
| 1670 | 1770 | ||
| ... | @@ -1683,7 +1783,7 @@ describe("WebSocket Server", () => { | ... | @@ -1683,7 +1783,7 @@ describe("WebSocket Server", () => { |
| 1683 | 1783 | ||
| 1684 | expect(response.result).toBeUndefined(); | 1784 | expect(response.result).toBeUndefined(); |
| 1685 | expect(response.error?.message).toContain( | 1785 | expect(response.error?.message).toContain( |
| 1686 | "Workspace file path must stay within the project root.", | 1786 | "Workspace file path must be relative to the project root: ../escape.md", |
| 1687 | ); | 1787 | ); |
| 1688 | expect(fs.existsSync(path.join(workspace, "..", "escape.md"))).toBe(false); | 1788 | expect(fs.existsSync(path.join(workspace, "..", "escape.md"))).toBe(false); |
| 1689 | }); | 1789 | }); |
apps/server/src/wsServer.ts+28-90| ... | @@ -50,7 +50,6 @@ import { GitManager } from "./git/Services/GitManager.ts"; | ... | @@ -50,7 +50,6 @@ import { GitManager } from "./git/Services/GitManager.ts"; |
| 50 | import { TerminalManager } from "./terminal/Services/Manager.ts"; | 50 | import { TerminalManager } from "./terminal/Services/Manager.ts"; |
| 51 | import { Keybindings } from "./keybindings"; | 51 | import { Keybindings } from "./keybindings"; |
| 52 | import { ServerSettingsService } from "./serverSettings"; | 52 | import { ServerSettingsService } from "./serverSettings"; |
| 53 | import { searchWorkspaceEntries } from "./workspaceEntries"; | ||
| 54 | import { OrchestrationEngineService } from "./orchestration/Services/OrchestrationEngine"; | 53 | import { OrchestrationEngineService } from "./orchestration/Services/OrchestrationEngine"; |
| 55 | import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery"; | 54 | import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery"; |
| 56 | import { OrchestrationReactor } from "./orchestration/Services/OrchestrationReactor"; | 55 | import { OrchestrationReactor } from "./orchestration/Services/OrchestrationReactor"; |
| ... | @@ -75,10 +74,13 @@ import { | ... | @@ -75,10 +74,13 @@ import { |
| 75 | } from "./attachmentStore.ts"; | 74 | } from "./attachmentStore.ts"; |
| 76 | import { parseBase64DataUrl } from "./imageMime.ts"; | 75 | import { parseBase64DataUrl } from "./imageMime.ts"; |
| 77 | import { AnalyticsService } from "./telemetry/Services/AnalyticsService.ts"; | 76 | import { AnalyticsService } from "./telemetry/Services/AnalyticsService.ts"; |
| 78 | import { expandHomePath } from "./os-jank.ts"; | ||
| 79 | import { makeServerPushBus } from "./wsServer/pushBus.ts"; | 77 | import { makeServerPushBus } from "./wsServer/pushBus.ts"; |
| 80 | import { makeServerReadiness } from "./wsServer/readiness.ts"; | 78 | import { makeServerReadiness } from "./wsServer/readiness.ts"; |
| 81 | import { decodeJsonResult, formatSchemaError } from "@t3tools/shared/schemaJson"; | 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 | * ServerShape - Service API for server lifecycle control. | 86 | * ServerShape - Service API for server lifecycle control. |
| ... | @@ -154,48 +156,6 @@ function websocketRawToString(raw: unknown): string | null { | ... | @@ -154,48 +156,6 @@ function websocketRawToString(raw: unknown): string | null { |
| 154 | return null; | 156 | return null; |
| 155 | } | 157 | } |
| 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 | function stripRequestTag<T extends { _tag: string }>(body: T) { | 159 | function stripRequestTag<T extends { _tag: string }>(body: T) { |
| 200 | return Struct.omit(body, ["_tag"]); | 160 | return Struct.omit(body, ["_tag"]); |
| 201 | } | 161 | } |
| ... | @@ -218,6 +178,10 @@ export type ServerRuntimeServices = | ... | @@ -218,6 +178,10 @@ export type ServerRuntimeServices = |
| 218 | | TerminalManager | 178 | | TerminalManager |
| 219 | | Keybindings | 179 | | Keybindings |
| 220 | | ServerSettingsService | 180 | | ServerSettingsService |
| 181 | | ProjectFaviconResolver | ||
| 182 | | WorkspaceEntries | ||
| 183 | | WorkspaceFileSystem | ||
| 184 | | WorkspacePaths | ||
| 221 | | Open | 185 | | Open |
| 222 | | AnalyticsService; | 186 | | AnalyticsService; |
| 223 | 187 | ||
| ... | @@ -263,6 +227,9 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< | ... | @@ -263,6 +227,9 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< |
| 263 | const serverSettingsManager = yield* ServerSettingsService; | 227 | const serverSettingsManager = yield* ServerSettingsService; |
| 264 | const providerRegistry = yield* ProviderRegistry; | 228 | const providerRegistry = yield* ProviderRegistry; |
| 265 | const git = yield* GitCore; | 229 | const git = yield* GitCore; |
| 230 | const workspaceEntries = yield* WorkspaceEntries; | ||
| 231 | const workspaceFileSystem = yield* WorkspaceFileSystem; | ||
| 232 | const workspacePaths = yield* WorkspacePaths; | ||
| 266 | const fileSystem = yield* FileSystem.FileSystem; | 233 | const fileSystem = yield* FileSystem.FileSystem; |
| 267 | const path = yield* Path.Path; | 234 | const path = yield* Path.Path; |
| 268 | 235 | ||
| ... | @@ -312,35 +279,21 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< | ... | @@ -312,35 +279,21 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< |
| 312 | const normalizeDispatchCommand = Effect.fnUntraced(function* (input: { | 279 | const normalizeDispatchCommand = Effect.fnUntraced(function* (input: { |
| 313 | readonly command: ClientOrchestrationCommand; | 280 | readonly command: ClientOrchestrationCommand; |
| 314 | }) { | 281 | }) { |
| 315 | const normalizeProjectWorkspaceRoot = Effect.fnUntraced(function* (workspaceRoot: string) { | ||
| 316 | const normalizedWorkspaceRoot = path.resolve(yield* expandHomePath(workspaceRoot.trim())); | ||
| 317 | const workspaceStat = yield* fileSystem | ||
| 318 | .stat(normalizedWorkspaceRoot) | ||
| 319 | .pipe(Effect.catch(() => Effect.succeed(null))); | ||
| 320 | if (!workspaceStat) { | ||
| 321 | return yield* new RouteRequestError({ | ||
| 322 | message: `Project directory does not exist: ${normalizedWorkspaceRoot}`, | ||
| 323 | }); | ||
| 324 | } | ||
| 325 | if (workspaceStat.type !== "Directory") { | ||
| 326 | return yield* new RouteRequestError({ | ||
| 327 | message: `Project path is not a directory: ${normalizedWorkspaceRoot}`, | ||
| 328 | }); | ||
| 329 | } | ||
| 330 | return normalizedWorkspaceRoot; | ||
| 331 | }); | ||
| 332 | |||
| 333 | if (input.command.type === "project.create") { | 282 | if (input.command.type === "project.create") { |
| 334 | return { | 283 | return { |
| 335 | ...input.command, | 284 | ...input.command, |
| 336 | workspaceRoot: yield* normalizeProjectWorkspaceRoot(input.command.workspaceRoot), | 285 | workspaceRoot: yield* workspacePaths |
| 286 | .normalizeWorkspaceRoot(input.command.workspaceRoot) | ||
| 287 | .pipe(Effect.mapError((cause) => new RouteRequestError({ message: cause.message }))), | ||
| 337 | } satisfies OrchestrationCommand; | 288 | } satisfies OrchestrationCommand; |
| 338 | } | 289 | } |
| 339 | 290 | ||
| 340 | if (input.command.type === "project.meta.update" && input.command.workspaceRoot !== undefined) { | 291 | if (input.command.type === "project.meta.update" && input.command.workspaceRoot !== undefined) { |
| 341 | return { | 292 | return { |
| 342 | ...input.command, | 293 | ...input.command, |
| 343 | workspaceRoot: yield* normalizeProjectWorkspaceRoot(input.command.workspaceRoot), | 294 | workspaceRoot: yield* workspacePaths |
| 295 | .normalizeWorkspaceRoot(input.command.workspaceRoot) | ||
| 296 | .pipe(Effect.mapError((cause) => new RouteRequestError({ message: cause.message }))), | ||
| 344 | } satisfies OrchestrationCommand; | 297 | } satisfies OrchestrationCommand; |
| 345 | } | 298 | } |
| 346 | 299 | ||
| ... | @@ -437,7 +390,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< | ... | @@ -437,7 +390,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< |
| 437 | void runPromise( | 390 | void runPromise( |
| 438 | Effect.gen(function* () { | 391 | Effect.gen(function* () { |
| 439 | const url = new URL(req.url ?? "/", `http://localhost:${port}`); | 392 | const url = new URL(req.url ?? "/", `http://localhost:${port}`); |
| 440 | if (tryHandleProjectFaviconRequest(url, res)) { | 393 | if (yield* tryHandleProjectFaviconRequest(url, res)) { |
| 441 | return; | 394 | return; |
| 442 | } | 395 | } |
| 443 | 396 | ||
| ... | @@ -768,41 +721,26 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< | ... | @@ -768,41 +721,26 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< |
| 768 | 721 | ||
| 769 | case WS_METHODS.projectsSearchEntries: { | 722 | case WS_METHODS.projectsSearchEntries: { |
| 770 | const body = stripRequestTag(request.body); | 723 | const body = stripRequestTag(request.body); |
| 771 | return yield* Effect.tryPromise({ | 724 | return yield* workspaceEntries.search(body).pipe( |
| 772 | try: () => searchWorkspaceEntries(body), | 725 | Effect.mapError( |
| 773 | catch: (cause) => | 726 | (cause) => |
| 774 | new RouteRequestError({ | 727 | new RouteRequestError({ |
| 775 | message: `Failed to search workspace entries: ${String(cause)}`, | 728 | message: `Failed to search workspace entries: ${cause.detail}`, |
| 776 | }), | 729 | }), |
| 777 | }); | 730 | ), |
| 731 | ); | ||
| 778 | } | 732 | } |
| 779 | 733 | ||
| 780 | case WS_METHODS.projectsWriteFile: { | 734 | case WS_METHODS.projectsWriteFile: { |
| 781 | const body = stripRequestTag(request.body); | 735 | const body = stripRequestTag(request.body); |
| 782 | const target = yield* resolveWorkspaceWritePath({ | 736 | return yield* workspaceFileSystem.writeFile(body).pipe( |
| 783 | workspaceRoot: body.cwd, | ||
| 784 | relativePath: body.relativePath, | ||
| 785 | path, | ||
| 786 | }); | ||
| 787 | yield* fileSystem | ||
| 788 | .makeDirectory(path.dirname(target.absolutePath), { recursive: true }) | ||
| 789 | .pipe( | ||
| 790 | Effect.mapError( | ||
| 791 | (cause) => | ||
| 792 | new RouteRequestError({ | ||
| 793 | message: `Failed to prepare workspace path: ${String(cause)}`, | ||
| 794 | }), | ||
| 795 | ), | ||
| 796 | ); | ||
| 797 | yield* fileSystem.writeFileString(target.absolutePath, body.contents).pipe( | ||
| 798 | Effect.mapError( | 737 | Effect.mapError( |
| 799 | (cause) => | 738 | (cause) => |
| 800 | new RouteRequestError({ | 739 | new RouteRequestError({ |
| 801 | message: `Failed to write workspace file: ${String(cause)}`, | 740 | message: `Failed to write workspace file: ${cause.message}`, |
| 802 | }), | 741 | }), |
| 803 | ), | 742 | ), |
| 804 | ); | 743 | ); |
| 805 | return { relativePath: target.relativePath }; | ||
| 806 | } | 744 | } |
| 807 | 745 | ||
| 808 | case WS_METHODS.shellOpenInEditor: { | 746 | case WS_METHODS.shellOpenInEditor: { |