From 48fc85e7fe69e3d28c0020adb950ad6b7cf79511 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 30 Mar 2026 15:08:25 -0700 Subject: [PATCH 1/5] Bump Effect catalog dependencies (#1594) --- bun.lock | 22 +++++++++++----------- package.json | 10 +++++----- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/bun.lock b/bun.lock index e13245b727..91cdc8ac97 100644 --- a/bun.lock +++ b/bun.lock @@ -176,13 +176,13 @@ "vite": "^8.0.0", }, "catalog": { - "@effect/language-service": "0.84.1", - "@effect/platform-node": "4.0.0-beta.42", - "@effect/sql-sqlite-bun": "4.0.0-beta.42", - "@effect/vitest": "4.0.0-beta.42", + "@effect/language-service": "0.84.2", + "@effect/platform-node": "4.0.0-beta.43", + "@effect/sql-sqlite-bun": "4.0.0-beta.43", + "@effect/vitest": "4.0.0-beta.43", "@types/bun": "^1.3.9", "@types/node": "^24.10.13", - "effect": "4.0.0-beta.42", + "effect": "4.0.0-beta.43", "tsdown": "^0.20.3", "typescript": "^5.7.3", "vitest": "^4.0.0", @@ -268,15 +268,15 @@ "@dnd-kit/utilities": ["@dnd-kit/utilities@3.2.2", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg=="], - "@effect/language-service": ["@effect/language-service@0.84.1", "", { "bin": { "effect-language-service": "cli.js" } }, "sha512-YUqjJU24HeYgPV453cR2fDqkZ+zZKMuxGnmxWAPscWJ6gt6FB7JZohMCOczRTIOGPrQMcloJX7BjCaPu+RNhpw=="], + "@effect/language-service": ["@effect/language-service@0.84.2", "", { "bin": { "effect-language-service": "cli.js" } }, "sha512-l04qNxpiA8rY5yXWckRPJ7Mk5MNerXuNymSFf+IdflfI5i8jgL1bpBNLuP6ijg7wgjdHc/KmTnCj2kT0SCntuA=="], - "@effect/platform-node": ["@effect/platform-node@4.0.0-beta.42", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0-beta.42", "mime": "^4.1.0", "undici": "^7.24.0" }, "peerDependencies": { "effect": "^4.0.0-beta.42", "ioredis": "^5.7.0" } }, "sha512-kbdRML2FBa4q8U8rZQcnmLKZ5zN/z1bAA7t5D1/UsBHZqJgnfRgu1CP6kaEfb1Nie6YyaWshxTktZQryjvW/Yg=="], + "@effect/platform-node": ["@effect/platform-node@4.0.0-beta.43", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0-beta.43", "mime": "^4.1.0", "undici": "^7.24.0" }, "peerDependencies": { "effect": "^4.0.0-beta.43", "ioredis": "^5.7.0" } }, "sha512-Uq6E1rjaIpjHauzjwoB2HzAg3battYt2Boy8XO50GoHiWCXKE6WapYZ0/AnaBx5v5qg2sOfqpuiLsUf9ZgxOkA=="], - "@effect/platform-node-shared": ["@effect/platform-node-shared@4.0.0-beta.42", "", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.19.0" }, "peerDependencies": { "effect": "^4.0.0-beta.42" } }, "sha512-PC+lxLsrwob3+nBChAPrQq32olCeyApgXBvs1NrRsoArLViNT76T/68CttuCAksCZj5e1bZ1ZibLPel3vUmx2g=="], + "@effect/platform-node-shared": ["@effect/platform-node-shared@4.0.0-beta.43", "", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.19.0" }, "peerDependencies": { "effect": "^4.0.0-beta.43" } }, "sha512-A9q0GEb61pYcQ06Dr6gXj1nKlDI3KHsar1sk3qb1ZY+kVSR64tBAylI8zGon23KY+NPtTUj/sEIToB7jc3Qt5w=="], - "@effect/sql-sqlite-bun": ["@effect/sql-sqlite-bun@4.0.0-beta.42", "", { "peerDependencies": { "effect": "^4.0.0-beta.42" } }, "sha512-Ah2QfkeV+I9r5OBVJijSDnFXCv51giBXngSwhju5gefc0uWiM3G1tsYAqrNX24HlvFFEnOAZqNf/Sq1h4NqOAA=="], + "@effect/sql-sqlite-bun": ["@effect/sql-sqlite-bun@4.0.0-beta.43", "", { "peerDependencies": { "effect": "^4.0.0-beta.43" } }, "sha512-Ryb7x0rBjW5NHegbzvz9y2Yv4KpwZ1UiPk7/Rv+Mkux74Qkjdmotmrvy9P+M5A9TZyL2Av+GpaLTPJKsL0PG+Q=="], - "@effect/vitest": ["@effect/vitest@4.0.0-beta.42", "", { "peerDependencies": { "effect": "^4.0.0-beta.42", "vitest": "^3.0.0 || ^4.0.0" } }, "sha512-/11arjUnCRhIrBRvOn/nrbg5p/FadjAPvStddZlpl1VrCxtB2s0n39cbG9uTyDdf1ZrRBG73Upo1ZDF1CTWy8w=="], + "@effect/vitest": ["@effect/vitest@4.0.0-beta.43", "", { "peerDependencies": { "effect": "^4.0.0-beta.43", "vitest": "^3.0.0 || ^4.0.0" } }, "sha512-XN2LAwiUWPqbV2jrsYYRjrVydQ8MIgwr83MVImtUaOQco4vk43+8OHlXQMRN/u2HnGK29KT+O2yTMMBdk2Q6Sw=="], "@electron/get": ["@electron/get@2.0.3", "", { "dependencies": { "debug": "^4.1.1", "env-paths": "^2.2.0", "fs-extra": "^8.1.0", "got": "^11.8.5", "progress": "^2.0.3", "semver": "^6.2.0", "sumchecker": "^3.0.1" }, "optionalDependencies": { "global-agent": "^3.0.0" } }, "sha512-Qkzpg2s9GnVV2I2BjRksUi43U5e6+zaQMcjoJy0C+C5oxaKl+fmckGDQFtRpZpZV0NQekuZZ+tGz7EA9TVnQtQ=="], @@ -1020,7 +1020,7 @@ "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], - "effect": ["effect@4.0.0-beta.42", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.5.3", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.8", "multipasta": "^0.2.7", "toml": "^3.0.0", "uuid": "^13.0.0", "yaml": "^2.8.2" } }, "sha512-c1UrRP+tLzyHb4Fepl8XBDJlLQLkrcMXrRBba441GQRxMbeQ/aIOSFcBwSda1iMJ5l9F0lYc3Bhe33/whrmavQ=="], + "effect": ["effect@4.0.0-beta.43", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.5.3", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.8", "multipasta": "^0.2.7", "toml": "^3.0.0", "uuid": "^13.0.0", "yaml": "^2.8.2" } }, "sha512-AJYyDimIwJOn87uUz/JzmgDc5GfjxJbXvEbTvNzMa+M3Uer344bLo/O5mMRkqc1vBleA+Ygs4+dbE3QsqOkKTQ=="], "electron": ["electron@40.6.0", "", { "dependencies": { "@electron/get": "^2.0.0", "@types/node": "^24.9.0", "extract-zip": "^2.0.1" }, "bin": { "electron": "cli.js" } }, "sha512-ett8W+yOFGDuM0vhJMamYSkrbV3LoaffzJd9GfjI96zRAxyrNqUSKqBpf/WGbQCweDxX2pkUCUfrv4wwKpsFZA=="], diff --git a/package.json b/package.json index 6f14f7b77f..aad78f7d4b 100644 --- a/package.json +++ b/package.json @@ -8,11 +8,11 @@ "scripts" ], "catalog": { - "effect": "4.0.0-beta.42", - "@effect/platform-node": "4.0.0-beta.42", - "@effect/sql-sqlite-bun": "4.0.0-beta.42", - "@effect/vitest": "4.0.0-beta.42", - "@effect/language-service": "0.84.1", + "effect": "4.0.0-beta.43", + "@effect/platform-node": "4.0.0-beta.43", + "@effect/sql-sqlite-bun": "4.0.0-beta.43", + "@effect/vitest": "4.0.0-beta.43", + "@effect/language-service": "0.84.2", "@types/bun": "^1.3.9", "@types/node": "^24.10.13", "tsdown": "^0.20.3", From d303ac39cbd97a94489b460f62eac153ad1857ad Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 30 Mar 2026 23:17:33 -0700 Subject: [PATCH 2/5] Replace wait-on with internal desktop resource polling (#1600) --- apps/desktop/package.json | 3 +- apps/desktop/scripts/dev-electron.mjs | 8 +- apps/desktop/scripts/wait-for-resources.mjs | 119 ++++++++++++++++++++ bun.lock | 63 ----------- 4 files changed, 125 insertions(+), 68 deletions(-) create mode 100644 apps/desktop/scripts/wait-for-resources.mjs diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 7ecb81c791..dfa3bde2f8 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -24,8 +24,7 @@ "@types/node": "catalog:", "tsdown": "catalog:", "typescript": "catalog:", - "vitest": "catalog:", - "wait-on": "^8.0.2" + "vitest": "catalog:" }, "productName": "T3 Code (Alpha)" } diff --git a/apps/desktop/scripts/dev-electron.mjs b/apps/desktop/scripts/dev-electron.mjs index 12d4753509..1506d80e9f 100644 --- a/apps/desktop/scripts/dev-electron.mjs +++ b/apps/desktop/scripts/dev-electron.mjs @@ -1,9 +1,9 @@ import { spawn, spawnSync } from "node:child_process"; import { watch } from "node:fs"; import { join } from "node:path"; -import waitOn from "wait-on"; import { desktopDir, resolveElectronPath } from "./electron-launcher.mjs"; +import { waitForResources } from "./wait-for-resources.mjs"; const port = Number(process.env.ELECTRON_RENDERER_PORT ?? 5733); const devServerUrl = `http://localhost:${port}`; @@ -20,8 +20,10 @@ const forcedShutdownTimeoutMs = 1_500; const restartDebounceMs = 120; const childTreeGracePeriodMs = 1_200; -await waitOn({ - resources: [`tcp:${port}`, ...requiredFiles.map((filePath) => `file:${filePath}`)], +await waitForResources({ + baseDir: desktopDir, + files: requiredFiles, + tcpPort: port, }); const childEnv = { ...process.env }; diff --git a/apps/desktop/scripts/wait-for-resources.mjs b/apps/desktop/scripts/wait-for-resources.mjs new file mode 100644 index 0000000000..2b0a60c5d9 --- /dev/null +++ b/apps/desktop/scripts/wait-for-resources.mjs @@ -0,0 +1,119 @@ +import * as FileSystem from "node:fs/promises"; +import * as Net from "node:net"; +import * as Path from "node:path"; +import * as Timers from "node:timers/promises"; + +const defaultTcpHosts = ["127.0.0.1", "localhost", "::1"]; + +async function fileExists(filePath) { + try { + await FileSystem.access(filePath); + return true; + } catch { + return false; + } +} + +function tcpPortIsReady({ host, port, connectTimeoutMs = 500 }) { + return new Promise((resolveReady) => { + const socket = Net.createConnection({ host, port }); + let settled = false; + + const finish = (ready) => { + if (settled) { + return; + } + + settled = true; + socket.removeAllListeners(); + socket.destroy(); + resolveReady(ready); + }; + + socket.once("connect", () => { + finish(true); + }); + socket.once("timeout", () => { + finish(false); + }); + socket.once("error", () => { + finish(false); + }); + socket.setTimeout(connectTimeoutMs); + }); +} + +async function resolvePendingResources({ baseDir, files, tcpPort, tcpHosts, connectTimeoutMs }) { + const pendingFiles = []; + + for (const relativeFilePath of files) { + const ready = await fileExists(Path.resolve(baseDir, relativeFilePath)); + if (!ready) { + pendingFiles.push(relativeFilePath); + } + } + + let tcpReady = false; + for (const host of tcpHosts) { + tcpReady = await tcpPortIsReady({ + host, + port: tcpPort, + connectTimeoutMs, + }); + if (tcpReady) { + break; + } + } + + return { + pendingFiles, + tcpReady, + }; +} + +export async function waitForResources({ + baseDir, + files = [], + intervalMs = 100, + timeoutMs = 120_000, + tcpHost, + tcpPort, + connectTimeoutMs = 500, +}) { + if (!Number.isInteger(tcpPort) || tcpPort <= 0) { + throw new TypeError("waitForResources requires a positive integer tcpPort"); + } + + const startedAt = Date.now(); + const tcpHosts = tcpHost ? [tcpHost] : defaultTcpHosts; + + while (true) { + const { pendingFiles, tcpReady } = await resolvePendingResources({ + baseDir, + files, + tcpPort, + tcpHosts, + connectTimeoutMs, + }); + + if (pendingFiles.length === 0 && tcpReady) { + return; + } + + if (Date.now() - startedAt >= timeoutMs) { + const pendingResources = []; + if (!tcpReady) { + pendingResources.push(tcpHost ? `tcp:${tcpHost}:${tcpPort}` : `tcp:${tcpPort}`); + } + for (const filePath of pendingFiles) { + pendingResources.push(`file:${filePath}`); + } + + throw new Error( + `Timed out waiting for desktop dev resources after ${timeoutMs}ms: ${pendingResources.join(", ")}`, + ); + } + + await Timers.setTimeout(intervalMs); + } +} diff --git a/bun.lock b/bun.lock index 91cdc8ac97..ee56cd52de 100644 --- a/bun.lock +++ b/bun.lock @@ -27,7 +27,6 @@ "tsdown": "catalog:", "typescript": "catalog:", "vitest": "catalog:", - "wait-on": "^8.0.2", }, }, "apps/marketing": { @@ -364,18 +363,6 @@ "@formkit/auto-animate": ["@formkit/auto-animate@0.9.0", "", {}, "sha512-VhP4zEAacXS3dfTpJpJ88QdLqMTcabMg0jwpOSxZ/VzfQVfl3GkZSCZThhGC5uhq/TxPHPzW0dzr4H9Bb1OgKA=="], - "@hapi/address": ["@hapi/address@5.1.1", "", { "dependencies": { "@hapi/hoek": "^11.0.2" } }, "sha512-A+po2d/dVoY7cYajycYI43ZbYMXukuopIsqCjh5QzsBCipDtdofHntljDlpccMjIfTy6UOkg+5KPriwYch2bXA=="], - - "@hapi/formula": ["@hapi/formula@3.0.2", "", {}, "sha512-hY5YPNXzw1He7s0iqkRQi+uMGh383CGdyyIGYtB+W5N3KHPXoqychklvHhKCC9M3Xtv0OCs/IHw+r4dcHtBYWw=="], - - "@hapi/hoek": ["@hapi/hoek@11.0.7", "", {}, "sha512-HV5undWkKzcB4RZUusqOpcgxOaq6VOAH7zhhIr2g3G8NF/MlFO75SjOr2NfuSx0Mh40+1FqCkagKLJRykUWoFQ=="], - - "@hapi/pinpoint": ["@hapi/pinpoint@2.0.1", "", {}, "sha512-EKQmr16tM8s16vTT3cA5L0kZZcTMU5DUOZTuvpnY738m+jyP3JIUj+Mm1xc1rsLkGBQ/gVnfKYPwOmPg1tUR4Q=="], - - "@hapi/tlds": ["@hapi/tlds@1.1.6", "", {}, "sha512-xdi7A/4NZokvV0ewovme3aUO5kQhW9pQ2YD1hRqZGhhSi5rBv4usHYidVocXSi9eihYsznZxLtAiEYYUL6VBGw=="], - - "@hapi/topo": ["@hapi/topo@6.0.2", "", { "dependencies": { "@hapi/hoek": "^11.0.2" } }, "sha512-KR3rD5inZbGMrHmgPxsJ9dbi6zEK+C3ZwUwTa+eMwWLz7oijWUTWD2pMSNNYJAU6Qq+65NkxXjqHr/7LM2Xkqg=="], - "@img/colour": ["@img/colour@1.1.0", "", {}, "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ=="], "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="], @@ -864,10 +851,6 @@ "astro": ["astro@6.0.5", "", { "dependencies": { "@astrojs/compiler": "^3.0.0", "@astrojs/internal-helpers": "0.8.0", "@astrojs/markdown-remark": "7.0.0", "@astrojs/telemetry": "3.3.0", "@capsizecss/unpack": "^4.0.0", "@clack/prompts": "^1.0.1", "@oslojs/encoding": "^1.1.0", "@rollup/pluginutils": "^5.3.0", "aria-query": "^5.3.2", "axobject-query": "^4.1.0", "ci-info": "^4.4.0", "clsx": "^2.1.1", "common-ancestor-path": "^2.0.0", "cookie": "^1.1.1", "devalue": "^5.6.3", "diff": "^8.0.3", "dlv": "^1.1.3", "dset": "^3.1.4", "es-module-lexer": "^2.0.0", "esbuild": "^0.27.3", "flattie": "^1.1.1", "fontace": "~0.4.1", "github-slugger": "^2.0.0", "html-escaper": "3.0.3", "http-cache-semantics": "^4.2.0", "js-yaml": "^4.1.1", "magic-string": "^0.30.21", "magicast": "^0.5.2", "mrmime": "^2.0.1", "neotraverse": "^0.6.18", "obug": "^2.1.1", "p-limit": "^7.3.0", "p-queue": "^9.1.0", "package-manager-detector": "^1.6.0", "piccolore": "^0.1.3", "picomatch": "^4.0.3", "rehype": "^13.0.2", "semver": "^7.7.4", "shiki": "^4.0.0", "smol-toml": "^1.6.0", "svgo": "^4.0.0", "tinyclip": "^0.1.6", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tsconfck": "^3.1.6", "ultrahtml": "^1.6.0", "unifont": "~0.7.4", "unist-util-visit": "^5.1.0", "unstorage": "^1.17.4", "vfile": "^6.0.3", "vite": "^7.3.1", "vitefu": "^1.1.2", "xxhash-wasm": "^1.1.0", "yargs-parser": "^22.0.0", "zod": "^4.3.6" }, "optionalDependencies": { "sharp": "^0.34.0" }, "bin": { "astro": "bin/astro.mjs" } }, "sha512-JnLCwaoCaRXIHuIB8yNztJrd7M3hXrHUMAoQmeXtEBKxRu/738REhaCZ1lapjrS9HlpHsWTu3JUXTERB/0PA7g=="], - "asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="], - - "axios": ["axios@1.13.6", "", { "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", "proxy-from-env": "^1.1.0" } }, "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ=="], - "axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="], "babel-dead-code-elimination": ["babel-dead-code-elimination@1.0.12", "", { "dependencies": { "@babel/core": "^7.23.7", "@babel/parser": "^7.23.6", "@babel/traverse": "^7.23.7", "@babel/types": "^7.23.6" } }, "sha512-GERT7L2TiYcYDtYk1IpD+ASAYXjKbLTDPhBtYj7X1NuRMDTMtAx9kyBenub1Ev41lo91OHCKdmP+egTDmfQ7Ig=="], @@ -904,8 +887,6 @@ "cacheable-request": ["cacheable-request@7.0.4", "", { "dependencies": { "clone-response": "^1.0.2", "get-stream": "^5.1.0", "http-cache-semantics": "^4.0.0", "keyv": "^4.0.0", "lowercase-keys": "^2.0.0", "normalize-url": "^6.0.1", "responselike": "^2.0.0" } }, "sha512-v+p6ongsrp0yTGbJXjgxPow2+DL93DASP4kXCDKb8/bwRtt9OEF3whggkkDkGNzgcWy2XaF4a8nZglC7uElscg=="], - "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], - "caniuse-lite": ["caniuse-lite@1.0.30001779", "", {}, "sha512-U5og2PN7V4DMgF50YPNtnZJGWVLFjjsN3zb6uMT5VGYIewieDj1upwfuVNXf4Kor+89c3iCRJnSzMD5LmTvsfA=="], "ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="], @@ -940,8 +921,6 @@ "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], - "combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="], - "comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="], "commander": ["commander@11.1.0", "", {}, "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ=="], @@ -986,8 +965,6 @@ "defu": ["defu@6.1.4", "", {}, "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg=="], - "delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="], - "denque": ["denque@2.1.0", "", {}, "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw=="], "dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="], @@ -1018,8 +995,6 @@ "dts-resolver": ["dts-resolver@2.1.3", "", { "peerDependencies": { "oxc-resolver": ">=11.0.0" }, "optionalPeers": ["oxc-resolver"] }, "sha512-bihc7jPC90VrosXNzK0LTE2cuLP6jr0Ro8jk+kMugHReJVLIpHz/xadeq3MhuwyO4TD4OA3L1Q8pBBFRc08Tsw=="], - "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], - "effect": ["effect@4.0.0-beta.43", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.5.3", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.8", "multipasta": "^0.2.7", "toml": "^3.0.0", "uuid": "^13.0.0", "yaml": "^2.8.2" } }, "sha512-AJYyDimIwJOn87uUz/JzmgDc5GfjxJbXvEbTvNzMa+M3Uer344bLo/O5mMRkqc1vBleA+Ygs4+dbE3QsqOkKTQ=="], "electron": ["electron@40.6.0", "", { "dependencies": { "@electron/get": "^2.0.0", "@types/node": "^24.9.0", "extract-zip": "^2.0.1" }, "bin": { "electron": "cli.js" } }, "sha512-ett8W+yOFGDuM0vhJMamYSkrbV3LoaffzJd9GfjI96zRAxyrNqUSKqBpf/WGbQCweDxX2pkUCUfrv4wwKpsFZA=="], @@ -1048,10 +1023,6 @@ "es-module-lexer": ["es-module-lexer@2.0.0", "", {}, "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw=="], - "es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="], - - "es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="], - "es6-error": ["es6-error@4.1.1", "", {}, "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg=="], "esbuild": ["esbuild@0.27.4", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.4", "@esbuild/android-arm": "0.27.4", "@esbuild/android-arm64": "0.27.4", "@esbuild/android-x64": "0.27.4", "@esbuild/darwin-arm64": "0.27.4", "@esbuild/darwin-x64": "0.27.4", "@esbuild/freebsd-arm64": "0.27.4", "@esbuild/freebsd-x64": "0.27.4", "@esbuild/linux-arm": "0.27.4", "@esbuild/linux-arm64": "0.27.4", "@esbuild/linux-ia32": "0.27.4", "@esbuild/linux-loong64": "0.27.4", "@esbuild/linux-mips64el": "0.27.4", "@esbuild/linux-ppc64": "0.27.4", "@esbuild/linux-riscv64": "0.27.4", "@esbuild/linux-s390x": "0.27.4", "@esbuild/linux-x64": "0.27.4", "@esbuild/netbsd-arm64": "0.27.4", "@esbuild/netbsd-x64": "0.27.4", "@esbuild/openbsd-arm64": "0.27.4", "@esbuild/openbsd-x64": "0.27.4", "@esbuild/openharmony-arm64": "0.27.4", "@esbuild/sunos-x64": "0.27.4", "@esbuild/win32-arm64": "0.27.4", "@esbuild/win32-ia32": "0.27.4", "@esbuild/win32-x64": "0.27.4" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ=="], @@ -1090,28 +1061,18 @@ "flattie": ["flattie@1.1.1", "", {}, "sha512-9UbaD6XdAL97+k/n+N7JwX46K/M6Zc6KcFYskrYL8wbBV/Uyk0CTAMY0VT+qiK5PM7AIc9aTWYtq65U7T+aCNQ=="], - "follow-redirects": ["follow-redirects@1.15.11", "", {}, "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ=="], - "fontace": ["fontace@0.4.1", "", { "dependencies": { "fontkitten": "^1.0.2" } }, "sha512-lDMvbAzSnHmbYMTEld5qdtvNH2/pWpICOqpean9IgC7vUbUJc3k+k5Dokp85CegamqQpFbXf0rAVkbzpyTA8aw=="], "fontkitten": ["fontkitten@1.0.3", "", { "dependencies": { "tiny-inflate": "^1.0.3" } }, "sha512-Wp1zXWPVUPBmfoa3Cqc9ctaKuzKAV6uLstRqlR56kSjplf5uAce+qeyYym7F+PHbGTk+tCEdkCW6RD7DX/gBZw=="], - "form-data": ["form-data@4.0.5", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w=="], - "fs-extra": ["fs-extra@10.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ=="], "fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], - "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], - "gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="], "get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="], - "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], - - "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], - "get-stream": ["get-stream@5.2.0", "", { "dependencies": { "pump": "^3.0.0" } }, "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA=="], "get-tsconfig": ["get-tsconfig@4.13.6", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw=="], @@ -1136,12 +1097,6 @@ "has-property-descriptors": ["has-property-descriptors@1.0.2", "", { "dependencies": { "es-define-property": "^1.0.0" } }, "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg=="], - "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], - - "has-tostringtag": ["has-tostringtag@1.0.2", "", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="], - - "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], - "hast-util-from-html": ["hast-util-from-html@2.0.3", "", { "dependencies": { "@types/hast": "^3.0.0", "devlop": "^1.1.0", "hast-util-from-parse5": "^8.0.0", "parse5": "^7.0.0", "vfile": "^6.0.0", "vfile-message": "^4.0.0" } }, "sha512-CUSRHXyKjzHov8yKsQjGOElXy/3EKpyX56ELnkHH34vDVw1N1XSQ1ZcAvTyAPtGqLTuKP/uxM+aLkSPqF/EtMw=="], "hast-util-from-parse5": ["hast-util-from-parse5@8.0.3", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "devlop": "^1.0.0", "hastscript": "^9.0.0", "property-information": "^7.0.0", "vfile": "^6.0.0", "vfile-location": "^5.0.0", "web-namespaces": "^2.0.0" } }, "sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg=="], @@ -1222,8 +1177,6 @@ "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], - "joi": ["joi@18.0.2", "", { "dependencies": { "@hapi/address": "^5.1.1", "@hapi/formula": "^3.0.2", "@hapi/hoek": "^11.0.7", "@hapi/pinpoint": "^2.0.1", "@hapi/tlds": "^1.1.1", "@hapi/topo": "^6.0.2", "@standard-schema/spec": "^1.0.0" } }, "sha512-RuCOQMIt78LWnktPoeBL0GErkNaJPTBGcYuyaBvUOQSpcpcLfWrHPPihYdOGbV5pam9VTWbeoF7TsGiHugcjGA=="], - "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], "js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], @@ -1278,8 +1231,6 @@ "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="], - "lodash": ["lodash@4.17.23", "", {}, "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w=="], - "lodash.defaults": ["lodash.defaults@4.2.0", "", {}, "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ=="], "lodash.escaperegexp": ["lodash.escaperegexp@4.1.2", "", {}, "sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw=="], @@ -1306,8 +1257,6 @@ "matcher": ["matcher@3.0.0", "", { "dependencies": { "escape-string-regexp": "^4.0.0" } }, "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng=="], - "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], - "mdast-util-definitions": ["mdast-util-definitions@6.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "unist-util-visit": "^5.0.0" } }, "sha512-scTllyX6pnYNZH/AIp/0ePz6s4cZtARxImwoPJ7kS42n+MnVsI4XbnG6d4ibehRIldYMWM2LD7ImQblVhUejVQ=="], "mdast-util-find-and-replace": ["mdast-util-find-and-replace@3.0.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "escape-string-regexp": "^5.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg=="], @@ -1400,14 +1349,8 @@ "mime": ["mime@4.1.0", "", { "bin": { "mime": "bin/cli.js" } }, "sha512-X5ju04+cAzsojXKes0B/S4tcYtFAJ6tTMuSPBEn9CPGlrWr8Fiw7qYeLT0XyH80HSoAoqWCaz+MWKh22P7G1cw=="], - "mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], - - "mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], - "mimic-response": ["mimic-response@3.1.0", "", {}, "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ=="], - "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], - "mrmime": ["mrmime@2.0.1", "", {}, "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ=="], "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], @@ -1516,8 +1459,6 @@ "property-information": ["property-information@7.1.0", "", {}, "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ=="], - "proxy-from-env": ["proxy-from-env@1.1.0", "", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="], - "pump": ["pump@3.0.4", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA=="], "pure-rand": ["pure-rand@8.1.0", "", {}, "sha512-53B3MB8wetRdD6JZ4W/0gDKaOvKwuXrEmV1auQc0hASWge8rieKV4PCCVNVbJ+i24miiubb4c/B+dg8Ho0ikYw=="], @@ -1600,8 +1541,6 @@ "run-applescript": ["run-applescript@7.1.0", "", {}, "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q=="], - "rxjs": ["rxjs@7.8.2", "", { "dependencies": { "tslib": "^2.1.0" } }, "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA=="], - "sax": ["sax@1.5.0", "", {}, "sha512-21IYA3Q5cQf089Z6tgaUTr7lDAyzoTPx5HRtbhsME8Udispad8dC/+sziTNugOEx54ilvatQ9YCzl4KQLPcRHA=="], "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], @@ -1840,8 +1779,6 @@ "vscode-uri": ["vscode-uri@3.1.0", "", {}, "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ=="], - "wait-on": ["wait-on@8.0.5", "", { "dependencies": { "axios": "^1.12.1", "joi": "^18.0.1", "lodash": "^4.17.21", "minimist": "^1.2.8", "rxjs": "^7.8.2" }, "bin": { "wait-on": "bin/wait-on" } }, "sha512-J3WlS0txVHkhLRb2FsmRg3dkMTCV1+M6Xra3Ho7HzZDHpE7DCOnoSoCJsZotrmW3uRMhvIJGSKUKrh/MeF4iag=="], - "web-namespaces": ["web-namespaces@2.0.1", "", {}, "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ=="], "webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="], From 2d03847ccd53ffcd5ba74301ce85aef2afe58ad5 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 31 Mar 2026 00:08:51 -0700 Subject: [PATCH 3/5] Extract project/workspace related functionality to Effect services (#1524) --- .../OrchestrationEngineHarness.integration.ts | 7 + apps/server/src/git/Layers/GitCore.test.ts | 90 ++- apps/server/src/git/Layers/GitCore.ts | 160 ++++- apps/server/src/git/Services/GitCore.ts | 28 + .../Layers/CheckpointReactor.test.ts | 5 +- .../orchestration/Layers/CheckpointReactor.ts | 7 +- .../Layers/ProjectFaviconResolver.test.ts | 74 +++ .../project/Layers/ProjectFaviconResolver.ts | 129 ++++ .../Services/ProjectFaviconResolver.ts | 30 + apps/server/src/projectFaviconRoute.test.ts | 28 +- apps/server/src/projectFaviconRoute.ts | 207 ++----- apps/server/src/serverLayers.ts | 25 +- .../workspace/Layers/WorkspaceEntries.test.ts | 264 ++++++++ .../src/workspace/Layers/WorkspaceEntries.ts | 478 +++++++++++++++ .../Layers/WorkspaceFileSystem.test.ts | 135 +++++ .../workspace/Layers/WorkspaceFileSystem.ts | 55 ++ .../workspace/Layers/WorkspacePaths.test.ts | 113 ++++ .../src/workspace/Layers/WorkspacePaths.ts | 89 +++ .../workspace/Services/WorkspaceEntries.ts | 48 ++ .../workspace/Services/WorkspaceFileSystem.ts | 50 ++ .../src/workspace/Services/WorkspacePaths.ts | 85 +++ .../src/workspaceEntries.chunking.test.ts | 81 --- apps/server/src/workspaceEntries.test.ts | 202 ------- apps/server/src/workspaceEntries.ts | 565 ------------------ apps/server/src/wsServer.test.ts | 144 ++++- apps/server/src/wsServer.ts | 118 +--- 26 files changed, 2077 insertions(+), 1140 deletions(-) create mode 100644 apps/server/src/project/Layers/ProjectFaviconResolver.test.ts create mode 100644 apps/server/src/project/Layers/ProjectFaviconResolver.ts create mode 100644 apps/server/src/project/Services/ProjectFaviconResolver.ts create mode 100644 apps/server/src/workspace/Layers/WorkspaceEntries.test.ts create mode 100644 apps/server/src/workspace/Layers/WorkspaceEntries.ts create mode 100644 apps/server/src/workspace/Layers/WorkspaceFileSystem.test.ts create mode 100644 apps/server/src/workspace/Layers/WorkspaceFileSystem.ts create mode 100644 apps/server/src/workspace/Layers/WorkspacePaths.test.ts create mode 100644 apps/server/src/workspace/Layers/WorkspacePaths.ts create mode 100644 apps/server/src/workspace/Services/WorkspaceEntries.ts create mode 100644 apps/server/src/workspace/Services/WorkspaceFileSystem.ts create mode 100644 apps/server/src/workspace/Services/WorkspacePaths.ts delete mode 100644 apps/server/src/workspaceEntries.chunking.test.ts delete mode 100644 apps/server/src/workspaceEntries.test.ts delete mode 100644 apps/server/src/workspaceEntries.ts diff --git a/apps/server/integration/OrchestrationEngineHarness.integration.ts b/apps/server/integration/OrchestrationEngineHarness.integration.ts index e66a214fb7..9340b9460d 100644 --- a/apps/server/integration/OrchestrationEngineHarness.integration.ts +++ b/apps/server/integration/OrchestrationEngineHarness.integration.ts @@ -68,6 +68,7 @@ import { type TestProviderAdapterHarness, } from "./TestProviderAdapter.integration.ts"; import { deriveServerPaths, ServerConfig } from "../src/config.ts"; +import { WorkspaceEntriesLive } from "../src/workspace/Layers/WorkspaceEntries.ts"; function runGit(cwd: string, args: ReadonlyArray) { return execFileSync("git", args, { @@ -317,6 +318,12 @@ export const makeOrchestrationIntegrationHarness = ( ); const checkpointReactorLayer = CheckpointReactorLive.pipe( Layer.provideMerge(runtimeServicesLayer), + Layer.provideMerge( + WorkspaceEntriesLive.pipe( + Layer.provideMerge(gitCoreLayer), + Layer.provide(NodeServices.layer), + ), + ), ); const orchestrationReactorLayer = OrchestrationReactorLive.pipe( Layer.provideMerge(runtimeIngestionLayer), diff --git a/apps/server/src/git/Layers/GitCore.test.ts b/apps/server/src/git/Layers/GitCore.test.ts index 547a69e7e1..135834f891 100644 --- a/apps/server/src/git/Layers/GitCore.test.ts +++ b/apps/server/src/git/Layers/GitCore.test.ts @@ -138,6 +138,13 @@ function buildLargeText(lineCount = 20_000): string { .concat("\n"); } +function splitNullSeparatedPaths(input: string): string[] { + return input + .split("\0") + .map((value) => value.trim()) + .filter((value) => value.length > 0); +} + // ── Tests ── it.layer(TestLayer)("git integration", (it) => { @@ -181,6 +188,55 @@ it.layer(TestLayer)("git integration", (it) => { ); }); + describe("workspace helpers", () => { + it.effect("filterIgnoredPaths chunks large path lists and preserves kept paths", () => + Effect.gen(function* () { + const cwd = "/virtual/repo"; + const relativePaths = Array.from({ length: 340 }, (_, index) => { + const prefix = index % 3 === 0 ? "ignored" : "kept"; + return `${prefix}/segment-${String(index).padStart(4, "0")}/${"x".repeat(900)}.ts`; + }); + const expectedPaths = relativePaths.filter( + (relativePath) => !relativePath.startsWith("ignored/"), + ); + + const seenChunks: string[][] = []; + const core = yield* makeIsolatedGitCore((input) => { + if (input.args.join(" ") !== "check-ignore --no-index -z --stdin") { + return Effect.fail( + new GitCommandError({ + operation: input.operation, + command: `git ${input.args.join(" ")}`, + cwd: input.cwd, + detail: "unexpected git command in chunking test", + }), + ); + } + + const chunkPaths = splitNullSeparatedPaths(input.stdin ?? ""); + seenChunks.push(chunkPaths); + const ignoredPaths = chunkPaths.filter((relativePath) => + relativePath.startsWith("ignored/"), + ); + + return Effect.succeed({ + code: ignoredPaths.length > 0 ? 0 : 1, + stdout: ignoredPaths.length > 0 ? `${ignoredPaths.join("\0")}\0` : "", + stderr: "", + stdoutTruncated: false, + stderrTruncated: false, + }); + }); + + const result = yield* core.filterIgnoredPaths(cwd, relativePaths); + + expect(seenChunks.length).toBeGreaterThan(1); + expect(seenChunks.flat()).toEqual(relativePaths); + expect(result).toEqual(expectedPaths); + }), + ); + }); + // ── listGitBranches ── describe("listGitBranches", () => { @@ -457,12 +513,18 @@ it.layer(TestLayer)("git integration", (it) => { yield* (yield* GitCore).checkoutBranch({ cwd: source, branch: featureBranch }); const core = yield* GitCore; yield* Effect.promise(() => - vi.waitFor(async () => { - const details = await runPromise(core.statusDetails(source)); - expect(details.branch).toBe(featureBranch); - expect(details.aheadCount).toBe(0); - expect(details.behindCount).toBe(1); - }), + vi.waitFor( + async () => { + const details = await runPromise(core.statusDetails(source)); + expect(details.branch).toBe(featureBranch); + expect(details.aheadCount).toBe(0); + expect(details.behindCount).toBe(1); + }, + { + timeout: 10_000, + interval: 100, + }, + ), ); }), ); @@ -541,7 +603,13 @@ it.layer(TestLayer)("git integration", (it) => { const core = yield* makeIsolatedGitCore((input) => { if (input.args[0] === "fetch") { fetchArgs = [...input.args]; - return Effect.succeed({ code: 0, stdout: "", stderr: "" }); + return Effect.succeed({ + code: 0, + stdout: "", + stderr: "", + stdoutTruncated: false, + stderrTruncated: false, + }); } return realGitCore.execute(input); }); @@ -594,7 +662,13 @@ it.layer(TestLayer)("git integration", (it) => { if (input.args[0] === "fetch") { fetchStarted = true; return Effect.promise(() => - waitForReleasePromise.then(() => ({ code: 0, stdout: "", stderr: "" })), + waitForReleasePromise.then(() => ({ + code: 0, + stdout: "", + stderr: "", + stdoutTruncated: false, + stderrTruncated: false, + })), ); } return realGitCore.execute(input); diff --git a/apps/server/src/git/Layers/GitCore.ts b/apps/server/src/git/Layers/GitCore.ts index 64ed409508..81d6cbb549 100644 --- a/apps/server/src/git/Layers/GitCore.ts +++ b/apps/server/src/git/Layers/GitCore.ts @@ -37,6 +37,8 @@ const PREPARED_COMMIT_PATCH_MAX_OUTPUT_BYTES = 49_000; const RANGE_COMMIT_SUMMARY_MAX_OUTPUT_BYTES = 19_000; const RANGE_DIFF_SUMMARY_MAX_OUTPUT_BYTES = 19_000; const RANGE_DIFF_PATCH_MAX_OUTPUT_BYTES = 59_000; +const WORKSPACE_FILES_MAX_OUTPUT_BYTES = 16 * 1024 * 1024; +const GIT_CHECK_IGNORE_MAX_STDIN_BYTES = 256 * 1024; const STATUS_UPSTREAM_REFRESH_INTERVAL = Duration.seconds(15); const STATUS_UPSTREAM_REFRESH_TIMEOUT = Duration.seconds(5); const STATUS_UPSTREAM_REFRESH_CACHE_CAPACITY = 2_048; @@ -55,6 +57,7 @@ class StatusUpstreamRefreshCacheKey extends Data.Class<{ }> {} interface ExecuteGitOptions { + stdin?: string | undefined; timeoutMs?: number | undefined; allowNonZeroExit?: boolean | undefined; fallbackErrorMessage?: string | undefined; @@ -96,6 +99,47 @@ function parseNumstatEntries( return entries; } +function splitNullSeparatedPaths(input: string, truncated: boolean): string[] { + const parts = input.split("\0"); + if (parts.length === 0) return []; + + if (truncated && parts[parts.length - 1]?.length) { + parts.pop(); + } + + return parts.filter((value) => value.length > 0); +} + +function chunkPathsForGitCheckIgnore(relativePaths: readonly string[]): string[][] { + const chunks: string[][] = []; + let chunk: string[] = []; + let chunkBytes = 0; + + for (const relativePath of relativePaths) { + const relativePathBytes = Buffer.byteLength(relativePath) + 1; + if (chunk.length > 0 && chunkBytes + relativePathBytes > GIT_CHECK_IGNORE_MAX_STDIN_BYTES) { + chunks.push(chunk); + chunk = []; + chunkBytes = 0; + } + + chunk.push(relativePath); + chunkBytes += relativePathBytes; + + if (chunkBytes >= GIT_CHECK_IGNORE_MAX_STDIN_BYTES) { + chunks.push(chunk); + chunk = []; + chunkBytes = 0; + } + } + + if (chunk.length > 0) { + chunks.push(chunk); + } + + return chunks; +} + function parsePorcelainPath(line: string): string | null { if (line.startsWith("? ") || line.startsWith("! ")) { const simple = line.slice(2).trim(); @@ -445,7 +489,7 @@ const collectOutput = Effect.fn("collectOutput")(function* ( maxOutputBytes: number, truncateOutputAtMaxBytes: boolean, onLine: ((line: string) => Effect.Effect) | undefined, -): Effect.fn.Return { +): Effect.fn.Return<{ readonly text: string; readonly truncated: boolean }, GitCommandError> { const decoder = new TextDecoder(); let bytes = 0; let text = ""; @@ -507,7 +551,10 @@ const collectOutput = Effect.fn("collectOutput")(function* ( text += remainder; lineBuffer += remainder; yield* emitCompleteLines(true); - return truncated ? `${text}${OUTPUT_TRUNCATED_MARKER}` : text; + return { + text, + truncated, + }; }); export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { @@ -571,13 +618,18 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { Effect.map((value) => Number(value)), Effect.mapError(toGitCommandError(commandInput, "failed to report exit code.")), ), + input.stdin === undefined + ? Effect.void + : Stream.run(Stream.encodeText(Stream.make(input.stdin)), child.stdin).pipe( + Effect.mapError(toGitCommandError(commandInput, "failed to write stdin.")), + ), ], { concurrency: "unbounded" }, - ); + ).pipe(Effect.map(([stdout, stderr, exitCode]) => [stdout, stderr, exitCode] as const)); yield* trace2Monitor.flush; if (!input.allowNonZeroExit && exitCode !== 0) { - const trimmedStderr = stderr.trim(); + const trimmedStderr = stderr.text.trim(); return yield* new GitCommandError({ operation: commandInput.operation, command: quoteGitCommand(commandInput.args), @@ -589,7 +641,13 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { }); } - return { code: exitCode, stdout, stderr } satisfies ExecuteGitResult; + return { + code: exitCode, + stdout: stdout.text, + stderr: stderr.text, + stdoutTruncated: stdout.truncated, + stderrTruncated: stderr.truncated, + } satisfies ExecuteGitResult; }); return yield* runGitCommand().pipe( @@ -618,11 +676,12 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { cwd: string, args: readonly string[], options: ExecuteGitOptions = {}, - ): Effect.Effect<{ code: number; stdout: string; stderr: string }, GitCommandError> => + ): Effect.Effect => execute({ operation, cwd, args, + ...(options.stdin !== undefined ? { stdin: options.stdin } : {}), allowNonZeroExit: true, ...(options.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}), ...(options.maxOutputBytes !== undefined ? { maxOutputBytes: options.maxOutputBytes } : {}), @@ -679,7 +738,11 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { args: readonly string[], options: ExecuteGitOptions = {}, ): Effect.Effect => - executeGit(operation, cwd, args, options).pipe(Effect.map((result) => result.stdout)); + executeGit(operation, cwd, args, options).pipe( + Effect.map((result) => + result.stdoutTruncated ? `${result.stdout}${OUTPUT_TRUNCATED_MARKER}` : result.stdout, + ), + ); const branchExists = (cwd: string, branch: string): Effect.Effect => executeGit( @@ -1416,6 +1479,86 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { Effect.map((trimmed) => (trimmed.length > 0 ? trimmed : null)), ); + const isInsideWorkTree: GitCoreShape["isInsideWorkTree"] = (cwd) => + executeGit("GitCore.isInsideWorkTree", cwd, ["rev-parse", "--is-inside-work-tree"], { + allowNonZeroExit: true, + timeoutMs: 5_000, + maxOutputBytes: 4_096, + }).pipe(Effect.map((result) => result.code === 0 && result.stdout.trim() === "true")); + + const listWorkspaceFiles: GitCoreShape["listWorkspaceFiles"] = (cwd) => + executeGit( + "GitCore.listWorkspaceFiles", + cwd, + ["ls-files", "--cached", "--others", "--exclude-standard", "-z"], + { + allowNonZeroExit: true, + timeoutMs: 20_000, + maxOutputBytes: WORKSPACE_FILES_MAX_OUTPUT_BYTES, + truncateOutputAtMaxBytes: true, + }, + ).pipe( + Effect.flatMap((result) => + result.code === 0 + ? Effect.succeed({ + paths: splitNullSeparatedPaths(result.stdout, result.stdoutTruncated), + truncated: result.stdoutTruncated, + }) + : Effect.fail( + createGitCommandError( + "GitCore.listWorkspaceFiles", + cwd, + ["ls-files", "--cached", "--others", "--exclude-standard", "-z"], + result.stderr.trim().length > 0 ? result.stderr.trim() : "git ls-files failed", + ), + ), + ), + ); + + const filterIgnoredPaths: GitCoreShape["filterIgnoredPaths"] = (cwd, relativePaths) => + Effect.gen(function* () { + if (relativePaths.length === 0) { + return relativePaths; + } + + const ignoredPaths = new Set(); + const chunks = chunkPathsForGitCheckIgnore(relativePaths); + + for (const chunk of chunks) { + const result = yield* executeGit( + "GitCore.filterIgnoredPaths", + cwd, + ["check-ignore", "--no-index", "-z", "--stdin"], + { + stdin: `${chunk.join("\0")}\0`, + allowNonZeroExit: true, + timeoutMs: 20_000, + maxOutputBytes: WORKSPACE_FILES_MAX_OUTPUT_BYTES, + truncateOutputAtMaxBytes: true, + }, + ); + + if (result.code !== 0 && result.code !== 1) { + return yield* createGitCommandError( + "GitCore.filterIgnoredPaths", + cwd, + ["check-ignore", "--no-index", "-z", "--stdin"], + result.stderr.trim().length > 0 ? result.stderr.trim() : "git check-ignore failed", + ); + } + + for (const ignoredPath of splitNullSeparatedPaths(result.stdout, result.stdoutTruncated)) { + ignoredPaths.add(ignoredPath); + } + } + + if (ignoredPaths.size === 0) { + return relativePaths; + } + + return relativePaths.filter((relativePath) => !ignoredPaths.has(relativePath)); + }); + const listBranches: GitCoreShape["listBranches"] = Effect.fn("listBranches")(function* (input) { const branchRecencyPromise = readBranchRecency(input.cwd).pipe( Effect.catch(() => Effect.succeed(new Map())), @@ -1834,6 +1977,9 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { pullCurrentBranch, readRangeContext, readConfigValue, + isInsideWorkTree, + listWorkspaceFiles, + filterIgnoredPaths, listBranches, createWorktree, fetchPullRequestBranch, diff --git a/apps/server/src/git/Services/GitCore.ts b/apps/server/src/git/Services/GitCore.ts index f1a4e065cd..3c30f17121 100644 --- a/apps/server/src/git/Services/GitCore.ts +++ b/apps/server/src/git/Services/GitCore.ts @@ -28,6 +28,7 @@ export interface ExecuteGitInput { readonly operation: string; readonly cwd: string; readonly args: ReadonlyArray; + readonly stdin?: string; readonly env?: NodeJS.ProcessEnv; readonly allowNonZeroExit?: boolean; readonly timeoutMs?: number; @@ -40,6 +41,8 @@ export interface ExecuteGitResult { readonly code: number; readonly stdout: string; readonly stderr: string; + readonly stdoutTruncated: boolean; + readonly stderrTruncated: boolean; } export interface GitStatusDetails extends Omit { @@ -93,6 +96,11 @@ export interface GitRangeContext { diffPatch: string; } +export interface GitListWorkspaceFilesResult { + readonly paths: ReadonlyArray; + readonly truncated: boolean; +} + export interface GitRenameBranchInput { cwd: string; oldBranch: string; @@ -190,6 +198,26 @@ export interface GitCoreShape { key: string, ) => Effect.Effect; + /** + * Determine whether the provided cwd is inside a git work tree. + */ + readonly isInsideWorkTree: (cwd: string) => Effect.Effect; + + /** + * List tracked and untracked workspace file paths relative to cwd. + */ + readonly listWorkspaceFiles: ( + cwd: string, + ) => Effect.Effect; + + /** + * Remove gitignored paths from a relative path list. + */ + readonly filterIgnoredPaths: ( + cwd: string, + relativePaths: ReadonlyArray, + ) => Effect.Effect, GitCommandError>; + /** * List local + remote branches and branch metadata. */ diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts index 075f62f889..7c201af375 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts @@ -38,6 +38,7 @@ import { } from "../../provider/Services/ProviderService.ts"; import { checkpointRefForThreadTurn } from "../../checkpointing/Utils.ts"; import { ServerConfig } from "../../config.ts"; +import { WorkspaceEntriesLive } from "../../workspace/Layers/WorkspaceEntries.ts"; const asProjectId = (value: string): ProjectId => ProjectId.makeUnsafe(value); const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value); @@ -261,7 +262,9 @@ describe("CheckpointReactor", () => { Layer.provideMerge(orchestrationLayer), Layer.provideMerge(RuntimeReceiptBusLive), Layer.provideMerge(Layer.succeed(ProviderService, provider.service)), - Layer.provideMerge(CheckpointStoreLive.pipe(Layer.provide(GitCoreLive))), + Layer.provideMerge(CheckpointStoreLive), + Layer.provideMerge(WorkspaceEntriesLive), + Layer.provideMerge(GitCoreLive), Layer.provideMerge(ServerConfigLayer), Layer.provideMerge(NodeServices.layer), ); diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.ts index 561626b8de..28609cecb3 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.ts @@ -16,7 +16,6 @@ import { checkpointRefForThreadTurn, resolveThreadWorkspaceCwd, } from "../../checkpointing/Utils.ts"; -import { clearWorkspaceIndexCache } from "../../workspaceEntries.ts"; import { CheckpointStore } from "../../checkpointing/Services/CheckpointStore.ts"; import { ProviderService } from "../../provider/Services/ProviderService.ts"; import { CheckpointReactor, type CheckpointReactorShape } from "../Services/CheckpointReactor.ts"; @@ -25,6 +24,7 @@ import { RuntimeReceiptBus } from "../Services/RuntimeReceiptBus.ts"; import { CheckpointStoreError } from "../../checkpointing/Errors.ts"; import { OrchestrationDispatchError } from "../Errors.ts"; import { isGitRepository } from "../../git/Utils.ts"; +import { WorkspaceEntries } from "../../workspace/Services/WorkspaceEntries.ts"; type ReactorInput = | { @@ -68,6 +68,7 @@ const make = Effect.gen(function* () { const providerService = yield* ProviderService; const checkpointStore = yield* CheckpointStore; const receiptBus = yield* RuntimeReceiptBus; + const workspaceEntries = yield* WorkspaceEntries; const appendRevertFailureActivity = (input: { readonly threadId: ThreadId; @@ -226,7 +227,7 @@ const make = Effect.gen(function* () { // Invalidate the workspace entry cache so the @-mention file picker // reflects files created or deleted during this turn. - clearWorkspaceIndexCache(input.cwd); + yield* workspaceEntries.invalidate(input.cwd); const files = yield* checkpointStore .diffCheckpoints({ @@ -642,7 +643,7 @@ const make = Effect.gen(function* () { // Invalidate the workspace entry cache so the @-mention file picker // reflects the reverted filesystem state. - clearWorkspaceIndexCache(sessionRuntime.value.cwd); + yield* workspaceEntries.invalidate(sessionRuntime.value.cwd); const rolledBackTurns = Math.max(0, currentTurnCount - event.payload.turnCount); if (rolledBackTurns > 0) { diff --git a/apps/server/src/project/Layers/ProjectFaviconResolver.test.ts b/apps/server/src/project/Layers/ProjectFaviconResolver.test.ts new file mode 100644 index 0000000000..29b30739b9 --- /dev/null +++ b/apps/server/src/project/Layers/ProjectFaviconResolver.test.ts @@ -0,0 +1,74 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { it, describe, expect } from "@effect/vitest"; +import { Effect, FileSystem, Layer, Path } from "effect"; + +import { ProjectFaviconResolver } from "../Services/ProjectFaviconResolver.ts"; +import { ProjectFaviconResolverLive } from "./ProjectFaviconResolver.ts"; + +const TestLayer = Layer.empty.pipe( + Layer.provideMerge(ProjectFaviconResolverLive), + Layer.provideMerge(NodeServices.layer), +); + +const makeTempDir = Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + return yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3code-project-favicon-", + }); +}); + +const writeTextFile = Effect.fn("writeTextFile")(function* ( + cwd: string, + relativePath: string, + contents: string, +) { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const absolutePath = path.join(cwd, relativePath); + yield* fileSystem + .makeDirectory(path.dirname(absolutePath), { recursive: true }) + .pipe(Effect.orDie); + yield* fileSystem.writeFileString(absolutePath, contents).pipe(Effect.orDie); +}); + +it.layer(TestLayer)("ProjectFaviconResolverLive", (it) => { + describe("resolvePath", () => { + it.effect("prefers well-known favicon files", () => + Effect.gen(function* () { + const resolver = yield* ProjectFaviconResolver; + const cwd = yield* makeTempDir; + yield* writeTextFile(cwd, "favicon.svg", "favicon"); + + const resolved = yield* resolver.resolvePath(cwd); + + expect(resolved).not.toBeNull(); + expect(resolved).toContain("favicon.svg"); + }), + ); + + it.effect("resolves icon hrefs from project source files", () => + Effect.gen(function* () { + const resolver = yield* ProjectFaviconResolver; + const cwd = yield* makeTempDir; + yield* writeTextFile(cwd, "index.html", ''); + yield* writeTextFile(cwd, "public/brand/logo.svg", "brand"); + + const resolved = yield* resolver.resolvePath(cwd); + + expect(resolved).not.toBeNull(); + expect(resolved).toContain("public/brand/logo.svg"); + }), + ); + + it.effect("returns null when no icon is present", () => + Effect.gen(function* () { + const resolver = yield* ProjectFaviconResolver; + const cwd = yield* makeTempDir; + + const resolved = yield* resolver.resolvePath(cwd); + + expect(resolved).toBeNull(); + }), + ); + }); +}); diff --git a/apps/server/src/project/Layers/ProjectFaviconResolver.ts b/apps/server/src/project/Layers/ProjectFaviconResolver.ts new file mode 100644 index 0000000000..3004a7a45c --- /dev/null +++ b/apps/server/src/project/Layers/ProjectFaviconResolver.ts @@ -0,0 +1,129 @@ +import { Effect, FileSystem, Layer, Path } from "effect"; + +import { + ProjectFaviconResolver, + type ProjectFaviconResolverShape, +} from "../Services/ProjectFaviconResolver.ts"; + +// Well-known favicon paths checked in order. +const FAVICON_CANDIDATES = [ + "favicon.svg", + "favicon.ico", + "favicon.png", + "public/favicon.svg", + "public/favicon.ico", + "public/favicon.png", + "app/favicon.ico", + "app/favicon.png", + "app/icon.svg", + "app/icon.png", + "app/icon.ico", + "src/favicon.ico", + "src/favicon.svg", + "src/app/favicon.ico", + "src/app/icon.svg", + "src/app/icon.png", + "assets/icon.svg", + "assets/icon.png", + "assets/logo.svg", + "assets/logo.png", +] as const; + +// Files that may contain a or icon metadata declaration. +const ICON_SOURCE_FILES = [ + "index.html", + "public/index.html", + "app/routes/__root.tsx", + "src/routes/__root.tsx", + "app/root.tsx", + "src/root.tsx", + "src/index.html", +] as const; + +// Matches tags or object-like icon metadata where rel/href can appear in any order. +const LINK_ICON_HTML_RE = + /]*\brel=["'](?:icon|shortcut icon)["'])(?=[^>]*\bhref=["']([^"'?]+))[^>]*>/i; +const LINK_ICON_OBJ_RE = + /(?=[^}]*\brel\s*:\s*["'](?:icon|shortcut icon)["'])(?=[^}]*\bhref\s*:\s*["']([^"'?]+))[^}]*/i; + +function extractIconHref(source: string): string | null { + const htmlMatch = source.match(LINK_ICON_HTML_RE); + if (htmlMatch?.[1]) return htmlMatch[1]; + const objMatch = source.match(LINK_ICON_OBJ_RE); + if (objMatch?.[1]) return objMatch[1]; + return null; +} + +export const makeProjectFaviconResolver = Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + + const resolveIconHref = (projectCwd: string, href: string): string[] => { + const clean = href.replace(/^\//, ""); + return [path.join(projectCwd, "public", clean), path.join(projectCwd, clean)]; + }; + + const isPathWithinProject = (projectCwd: string, candidatePath: string): boolean => { + const relative = path.relative(path.resolve(projectCwd), path.resolve(candidatePath)); + return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); + }; + + const findExistingFile = Effect.fn("ProjectFaviconResolver.findExistingFile")(function* ( + projectCwd: string, + candidates: ReadonlyArray, + ): Effect.fn.Return { + for (const candidate of candidates) { + if (!isPathWithinProject(projectCwd, candidate)) { + continue; + } + const stats = yield* fileSystem + .stat(candidate) + .pipe(Effect.catch(() => Effect.succeed(null))); + if (stats?.type === "File") { + return candidate; + } + } + return null; + }); + + const resolvePath: ProjectFaviconResolverShape["resolvePath"] = Effect.fn( + "ProjectFaviconResolver.resolvePath", + )(function* (cwd: string): Effect.fn.Return { + for (const candidate of FAVICON_CANDIDATES) { + const resolved = path.join(cwd, candidate); + const existing = yield* findExistingFile(cwd, [resolved]); + if (existing) { + return existing; + } + } + + for (const sourceFile of ICON_SOURCE_FILES) { + const sourcePath = path.join(cwd, sourceFile); + const source = yield* fileSystem + .readFileString(sourcePath) + .pipe(Effect.catch(() => Effect.succeed(null))); + if (!source) { + continue; + } + const href = extractIconHref(source); + if (!href) { + continue; + } + const existing = yield* findExistingFile(cwd, resolveIconHref(cwd, href)); + if (existing) { + return existing; + } + } + + return null; + }); + + return { + resolvePath, + } satisfies ProjectFaviconResolverShape; +}); + +export const ProjectFaviconResolverLive = Layer.effect( + ProjectFaviconResolver, + makeProjectFaviconResolver, +); diff --git a/apps/server/src/project/Services/ProjectFaviconResolver.ts b/apps/server/src/project/Services/ProjectFaviconResolver.ts new file mode 100644 index 0000000000..f6a8e22c0d --- /dev/null +++ b/apps/server/src/project/Services/ProjectFaviconResolver.ts @@ -0,0 +1,30 @@ +/** + * ProjectFaviconResolver - Effect service contract for project icon discovery. + * + * Resolves a representative favicon or app icon file for a workspace by + * checking common file locations and project source metadata. + * + * @module ProjectFaviconResolver + */ +import { ServiceMap } from "effect"; +import type { Effect } from "effect"; + +/** + * ProjectFaviconResolverShape - Service API for project favicon lookup. + */ +export interface ProjectFaviconResolverShape { + /** + * Resolve a favicon or icon file path for the provided workspace root. + * + * Returns `null` when no candidate icon file can be found. + */ + readonly resolvePath: (cwd: string) => Effect.Effect; +} + +/** + * ProjectFaviconResolver - Service tag for project favicon resolution. + */ +export class ProjectFaviconResolver extends ServiceMap.Service< + ProjectFaviconResolver, + ProjectFaviconResolverShape +>()("t3/project/Services/ProjectFaviconResolver") {} diff --git a/apps/server/src/projectFaviconRoute.test.ts b/apps/server/src/projectFaviconRoute.test.ts index a346e513eb..99b61aedc9 100644 --- a/apps/server/src/projectFaviconRoute.test.ts +++ b/apps/server/src/projectFaviconRoute.test.ts @@ -3,7 +3,11 @@ import http from "node:http"; import os from "node:os"; import path from "node:path"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { Effect } from "effect"; import { afterEach, describe, expect, it } from "vitest"; + +import { ProjectFaviconResolverLive } from "./project/Layers/ProjectFaviconResolver.ts"; import { tryHandleProjectFaviconRequest } from "./projectFaviconRoute"; interface HttpResponse { @@ -23,11 +27,25 @@ function makeTempDir(prefix: string): string { async function withRouteServer(run: (baseUrl: string) => Promise): Promise { const server = http.createServer((req, res) => { const url = new URL(req.url ?? "/", "http://127.0.0.1"); - if (tryHandleProjectFaviconRequest(url, res)) { - return; - } - res.writeHead(404, { "Content-Type": "text/plain" }); - res.end("Not Found"); + void Effect.runPromise( + tryHandleProjectFaviconRequest(url, res).pipe( + Effect.provide(ProjectFaviconResolverLive), + Effect.provide(NodeServices.layer), + Effect.flatMap((handled) => + handled + ? Effect.void + : Effect.sync(() => { + res.writeHead(404, { "Content-Type": "text/plain" }); + res.end("Not Found"); + }), + ), + ), + ).catch(() => { + if (!res.headersSent) { + res.writeHead(500, { "Content-Type": "text/plain" }); + res.end("Internal Server Error"); + } + }); }); await new Promise((resolve, reject) => { diff --git a/apps/server/src/projectFaviconRoute.ts b/apps/server/src/projectFaviconRoute.ts index cf234ad894..56a9fc9035 100644 --- a/apps/server/src/projectFaviconRoute.ts +++ b/apps/server/src/projectFaviconRoute.ts @@ -1,6 +1,8 @@ -import fs from "node:fs"; import http from "node:http"; import path from "node:path"; +import { Effect, FileSystem } from "effect"; + +import { ProjectFaviconResolver } from "./project/Services/ProjectFaviconResolver.ts"; const FAVICON_MIME_TYPES: Record = { ".png": "image/png", @@ -11,161 +13,64 @@ const FAVICON_MIME_TYPES: Record = { const FALLBACK_FAVICON_SVG = ``; -// Well-known favicon paths checked in order. -const FAVICON_CANDIDATES = [ - "favicon.svg", - "favicon.ico", - "favicon.png", - "public/favicon.svg", - "public/favicon.ico", - "public/favicon.png", - "app/favicon.ico", - "app/favicon.png", - "app/icon.svg", - "app/icon.png", - "app/icon.ico", - "src/favicon.ico", - "src/favicon.svg", - "src/app/favicon.ico", - "src/app/icon.svg", - "src/app/icon.png", - "assets/icon.svg", - "assets/icon.png", - "assets/logo.svg", - "assets/logo.png", -]; - -// Files that may contain a or icon metadata declaration. -const ICON_SOURCE_FILES = [ - "index.html", - "public/index.html", - "app/routes/__root.tsx", - "src/routes/__root.tsx", - "app/root.tsx", - "src/root.tsx", - "src/index.html", -]; - -// Matches tags or object-like icon metadata where rel/href can appear in any order. -const LINK_ICON_HTML_RE = - /]*\brel=["'](?:icon|shortcut icon)["'])(?=[^>]*\bhref=["']([^"'?]+))[^>]*>/i; -const LINK_ICON_OBJ_RE = - /(?=[^}]*\brel\s*:\s*["'](?:icon|shortcut icon)["'])(?=[^}]*\bhref\s*:\s*["']([^"'?]+))[^}]*/i; - -function extractIconHref(source: string): string | null { - const htmlMatch = source.match(LINK_ICON_HTML_RE); - if (htmlMatch?.[1]) return htmlMatch[1]; - const objMatch = source.match(LINK_ICON_OBJ_RE); - if (objMatch?.[1]) return objMatch[1]; - return null; -} - -function resolveIconHref(projectCwd: string, href: string): string[] { - const clean = href.replace(/^\//, ""); - return [path.join(projectCwd, "public", clean), path.join(projectCwd, clean)]; -} - -function isPathWithinProject(projectCwd: string, candidatePath: string): boolean { - const relative = path.relative(path.resolve(projectCwd), path.resolve(candidatePath)); - return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); -} - -function serveFaviconFile(filePath: string, res: http.ServerResponse): void { - const ext = path.extname(filePath).toLowerCase(); - const contentType = FAVICON_MIME_TYPES[ext] ?? "application/octet-stream"; - fs.readFile(filePath, (readErr, data) => { - if (readErr) { - res.writeHead(500, { "Content-Type": "text/plain" }); - res.end("Read error"); - return; +export const tryHandleProjectFaviconRequest = Effect.fn("tryHandleProjectFaviconRequest")( + function* ( + url: URL, + res: http.ServerResponse, + ): Effect.fn.Return { + const respond = ( + statusCode: number, + headers: Record, + body: string | Uint8Array, + ) => { + res.writeHead(statusCode, headers); + res.end(body); + }; + + if (url.pathname !== "/api/project-favicon") { + return false; } - res.writeHead(200, { - "Content-Type": contentType, - "Cache-Control": "public, max-age=3600", - }); - res.end(data); - }); -} -function serveFallbackFavicon(res: http.ServerResponse): void { - res.writeHead(200, { - "Content-Type": "image/svg+xml", - "Cache-Control": "public, max-age=3600", - }); - res.end(FALLBACK_FAVICON_SVG); -} - -export function tryHandleProjectFaviconRequest(url: URL, res: http.ServerResponse): boolean { - if (url.pathname !== "/api/project-favicon") { - return false; - } - - const projectCwd = url.searchParams.get("cwd"); - if (!projectCwd) { - res.writeHead(400, { "Content-Type": "text/plain" }); - res.end("Missing cwd parameter"); - return true; - } - - const tryResolvedPaths = (paths: string[], index: number, onExhausted: () => void): void => { - if (index >= paths.length) { - onExhausted(); - return; + const projectCwd = url.searchParams.get("cwd"); + if (!projectCwd) { + respond(400, { "Content-Type": "text/plain" }, "Missing cwd parameter"); + return true; } - const candidate = paths[index]!; - if (!isPathWithinProject(projectCwd, candidate)) { - tryResolvedPaths(paths, index + 1, onExhausted); - return; - } - fs.stat(candidate, (err, stats) => { - if (err || !stats?.isFile()) { - tryResolvedPaths(paths, index + 1, onExhausted); - return; - } - serveFaviconFile(candidate, res); - }); - }; - const trySourceFiles = (index: number): void => { - if (index >= ICON_SOURCE_FILES.length) { - serveFallbackFavicon(res); - return; + const fileSystem = yield* FileSystem.FileSystem; + const faviconResolver = yield* ProjectFaviconResolver; + const resolvedPath = yield* faviconResolver.resolvePath(projectCwd); + + if (!resolvedPath) { + respond( + 200, + { + "Content-Type": "image/svg+xml", + "Cache-Control": "public, max-age=3600", + }, + FALLBACK_FAVICON_SVG, + ); + return true; } - const sourceFile = path.join(projectCwd, ICON_SOURCE_FILES[index]!); - fs.readFile(sourceFile, "utf8", (err, content) => { - if (err) { - trySourceFiles(index + 1); - return; - } - const href = extractIconHref(content); - if (!href) { - trySourceFiles(index + 1); - return; - } - const candidates = resolveIconHref(projectCwd, href); - tryResolvedPaths(candidates, 0, () => trySourceFiles(index + 1)); - }); - }; - const tryCandidates = (index: number): void => { - if (index >= FAVICON_CANDIDATES.length) { - trySourceFiles(0); - return; - } - const candidate = path.join(projectCwd, FAVICON_CANDIDATES[index]!); - if (!isPathWithinProject(projectCwd, candidate)) { - tryCandidates(index + 1); - return; + const data = yield* fileSystem + .readFile(resolvedPath) + .pipe(Effect.catch(() => Effect.succeed(null))); + if (!data) { + respond(500, { "Content-Type": "text/plain" }, "Read error"); + return true; } - fs.stat(candidate, (err, stats) => { - if (err || !stats?.isFile()) { - tryCandidates(index + 1); - return; - } - serveFaviconFile(candidate, res); - }); - }; - tryCandidates(0); - return true; -} + const ext = path.extname(resolvedPath).toLowerCase(); + const contentType = FAVICON_MIME_TYPES[ext] ?? "application/octet-stream"; + respond( + 200, + { + "Content-Type": contentType, + "Cache-Control": "public, max-age=3600", + }, + data, + ); + return true; + }, +); diff --git a/apps/server/src/serverLayers.ts b/apps/server/src/serverLayers.ts index a8c1a13f7f..f745d553a1 100644 --- a/apps/server/src/serverLayers.ts +++ b/apps/server/src/serverLayers.ts @@ -34,6 +34,10 @@ import { GitHubCliLive } from "./git/Layers/GitHubCli"; import { RoutingTextGenerationLive } from "./git/Layers/RoutingTextGeneration"; import { PtyAdapter } from "./terminal/Services/PTY"; import { AnalyticsService } from "./telemetry/Services/AnalyticsService"; +import { ProjectFaviconResolverLive } from "./project/Layers/ProjectFaviconResolver.ts"; +import { WorkspaceEntriesLive } from "./workspace/Layers/WorkspaceEntries.ts"; +import { WorkspaceFileSystemLive } from "./workspace/Layers/WorkspaceFileSystem.ts"; +import { WorkspacePathsLive } from "./workspace/Layers/WorkspacePaths.ts"; type RuntimePtyAdapterLoader = { layer: Layer.Layer; @@ -91,7 +95,8 @@ export function makeServerProviderLayer(): Layer.Layer< export function makeServerRuntimeServicesLayer() { const textGenerationLayer = RoutingTextGenerationLive; - const checkpointStoreLayer = CheckpointStoreLive.pipe(Layer.provide(GitCoreLive)); + const gitCoreLayer = GitCoreLive; + const checkpointStoreLayer = CheckpointStoreLive; const orchestrationLayer = OrchestrationEngineLive.pipe( Layer.provide(OrchestrationProjectionPipelineLive), @@ -116,11 +121,11 @@ export function makeServerRuntimeServicesLayer() { ); const providerCommandReactorLayer = ProviderCommandReactorLive.pipe( Layer.provideMerge(runtimeServicesLayer), - Layer.provideMerge(GitCoreLive), Layer.provideMerge(textGenerationLayer), ); const checkpointReactorLayer = CheckpointReactorLive.pipe( Layer.provideMerge(runtimeServicesLayer), + Layer.provideMerge(WorkspaceEntriesLive), ); const orchestrationReactorLayer = OrchestrationReactorLive.pipe( Layer.provideMerge(runtimeIngestionLayer), @@ -131,16 +136,26 @@ export function makeServerRuntimeServicesLayer() { const terminalLayer = TerminalManagerLive.pipe(Layer.provide(makeRuntimePtyAdapterLayer())); const gitManagerLayer = GitManagerLive.pipe( - Layer.provideMerge(GitCoreLive), Layer.provideMerge(GitHubCliLive), Layer.provideMerge(textGenerationLayer), ); + const workspacePathsLayer = WorkspacePathsLive; + const workspaceEntriesLayer = WorkspaceEntriesLive; + const workspaceFileSystemLayer = WorkspaceFileSystemLive.pipe( + Layer.provide(workspacePathsLayer), + Layer.provide(workspaceEntriesLayer), + ); + const projectFaviconResolverLayer = ProjectFaviconResolverLive; + return Layer.mergeAll( orchestrationReactorLayer, - GitCoreLive, + workspacePathsLayer, + workspaceEntriesLayer, + workspaceFileSystemLayer, + projectFaviconResolverLayer, gitManagerLayer, terminalLayer, KeybindingsLive, - ).pipe(Layer.provideMerge(NodeServices.layer)); + ).pipe(Layer.provideMerge(gitCoreLayer), Layer.provideMerge(NodeServices.layer)); } diff --git a/apps/server/src/workspace/Layers/WorkspaceEntries.test.ts b/apps/server/src/workspace/Layers/WorkspaceEntries.test.ts new file mode 100644 index 0000000000..1d1eb4f0e2 --- /dev/null +++ b/apps/server/src/workspace/Layers/WorkspaceEntries.test.ts @@ -0,0 +1,264 @@ +import fsPromises from "node:fs/promises"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { it, afterEach, describe, expect, vi } from "@effect/vitest"; +import { Effect, FileSystem, Layer, Path, PlatformError } from "effect"; + +import { ServerConfig } from "../../config.ts"; +import { GitCoreLive } from "../../git/Layers/GitCore.ts"; +import { GitCore } from "../../git/Services/GitCore.ts"; +import { WorkspaceEntries } from "../Services/WorkspaceEntries.ts"; +import { WorkspaceEntriesLive } from "./WorkspaceEntries.ts"; + +const TestLayer = Layer.empty.pipe( + Layer.provideMerge(WorkspaceEntriesLive), + Layer.provideMerge(GitCoreLive), + Layer.provide( + ServerConfig.layerTest(process.cwd(), { + prefix: "t3-workspace-entries-test-", + }), + ), + Layer.provideMerge(NodeServices.layer), +); + +const makeTempDir = Effect.fn(function* (opts?: { prefix?: string; git?: boolean }) { + const fileSystem = yield* FileSystem.FileSystem; + const gitCore = yield* GitCore; + const dir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: opts?.prefix ?? "t3code-workspace-entries-", + }); + if (opts?.git) { + yield* gitCore.initRepo({ cwd: dir }); + } + return dir; +}); + +function writeTextFile( + cwd: string, + relativePath: string, + contents = "", +): Effect.Effect { + return Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const absolutePath = path.join(cwd, relativePath); + yield* fileSystem.makeDirectory(path.dirname(absolutePath), { recursive: true }); + yield* fileSystem.writeFileString(absolutePath, contents); + }); +} + +const git = (cwd: string, args: ReadonlyArray, env?: NodeJS.ProcessEnv) => + Effect.gen(function* () { + const gitCore = yield* GitCore; + const result = yield* gitCore.execute({ + operation: "WorkspaceEntries.test.git", + cwd, + args, + ...(env ? { env } : {}), + timeoutMs: 10_000, + }); + return result.stdout.trim(); + }); + +const searchWorkspaceEntries = (input: { cwd: string; query: string; limit: number }) => + Effect.gen(function* () { + const workspaceEntries = yield* WorkspaceEntries; + return yield* workspaceEntries.search(input); + }); + +it.layer(TestLayer)("WorkspaceEntriesLive", (it) => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe("search", () => { + it.effect("returns files and directories relative to cwd", () => + Effect.gen(function* () { + const cwd = yield* makeTempDir(); + yield* writeTextFile(cwd, "src/components/Composer.tsx"); + yield* writeTextFile(cwd, "src/index.ts"); + yield* writeTextFile(cwd, "README.md"); + yield* writeTextFile(cwd, ".git/HEAD"); + yield* writeTextFile(cwd, "node_modules/pkg/index.js"); + + const result = yield* searchWorkspaceEntries({ cwd, query: "", limit: 100 }); + const paths = result.entries.map((entry) => entry.path); + + expect(paths).toContain("src"); + expect(paths).toContain("src/components"); + expect(paths).toContain("src/components/Composer.tsx"); + expect(paths).toContain("README.md"); + expect(paths.some((entryPath) => entryPath.startsWith(".git"))).toBe(false); + expect(paths.some((entryPath) => entryPath.startsWith("node_modules"))).toBe(false); + expect(result.truncated).toBe(false); + }), + ); + + it.effect("filters and ranks entries by query", () => + Effect.gen(function* () { + const cwd = yield* makeTempDir({ prefix: "t3code-workspace-query-" }); + yield* writeTextFile(cwd, "src/components/Composer.tsx"); + yield* writeTextFile(cwd, "src/components/composePrompt.ts"); + yield* writeTextFile(cwd, "docs/composition.md"); + + const result = yield* searchWorkspaceEntries({ cwd, query: "compo", limit: 5 }); + + expect(result.entries.length).toBeGreaterThan(0); + expect(result.entries.some((entry) => entry.path === "src/components")).toBe(true); + expect(result.entries.every((entry) => entry.path.toLowerCase().includes("compo"))).toBe( + true, + ); + }), + ); + + it.effect("supports fuzzy subsequence queries for composer path search", () => + Effect.gen(function* () { + const cwd = yield* makeTempDir({ prefix: "t3code-workspace-fuzzy-query-" }); + yield* writeTextFile(cwd, "src/components/Composer.tsx"); + yield* writeTextFile(cwd, "src/components/composePrompt.ts"); + yield* writeTextFile(cwd, "docs/composition.md"); + + const result = yield* searchWorkspaceEntries({ cwd, query: "cmp", limit: 10 }); + const paths = result.entries.map((entry) => entry.path); + + expect(result.entries.length).toBeGreaterThan(0); + expect(paths).toContain("src/components"); + expect(paths).toContain("src/components/Composer.tsx"); + }), + ); + + it.effect("tracks truncation without sorting every fuzzy match", () => + Effect.gen(function* () { + const cwd = yield* makeTempDir({ prefix: "t3code-workspace-fuzzy-limit-" }); + yield* writeTextFile(cwd, "src/components/Composer.tsx"); + yield* writeTextFile(cwd, "src/components/composePrompt.ts"); + yield* writeTextFile(cwd, "docs/composition.md"); + + const result = yield* searchWorkspaceEntries({ cwd, query: "cmp", limit: 1 }); + + expect(result.entries).toHaveLength(1); + expect(result.truncated).toBe(true); + }), + ); + + it.effect("excludes gitignored paths for git repositories", () => + Effect.gen(function* () { + const cwd = yield* makeTempDir({ prefix: "t3code-workspace-gitignore-", git: true }); + yield* writeTextFile(cwd, ".gitignore", ".convex/\nconvex/\nignored.txt\n"); + yield* writeTextFile(cwd, "src/keep.ts", "export {};"); + yield* writeTextFile(cwd, "ignored.txt", "ignore me"); + yield* writeTextFile(cwd, ".convex/local-storage/data.json", "{}"); + yield* writeTextFile(cwd, "convex/UOoS-l/convex_local_storage/modules/data.json", "{}"); + + const result = yield* searchWorkspaceEntries({ cwd, query: "", limit: 100 }); + const paths = result.entries.map((entry) => entry.path); + + expect(paths).toContain("src"); + expect(paths).toContain("src/keep.ts"); + expect(paths).not.toContain("ignored.txt"); + expect(paths.some((entryPath) => entryPath.startsWith(".convex/"))).toBe(false); + expect(paths.some((entryPath) => entryPath.startsWith("convex/"))).toBe(false); + }), + ); + + it.effect("excludes tracked paths that match ignore rules", () => + Effect.gen(function* () { + const cwd = yield* makeTempDir({ + prefix: "t3code-workspace-tracked-gitignore-", + git: true, + }); + yield* writeTextFile(cwd, ".convex/local-storage/data.json", "{}"); + yield* writeTextFile(cwd, "src/keep.ts", "export {};"); + yield* git(cwd, ["add", ".convex/local-storage/data.json", "src/keep.ts"]); + yield* writeTextFile(cwd, ".gitignore", ".convex/\n"); + + const result = yield* searchWorkspaceEntries({ cwd, query: "", limit: 100 }); + const paths = result.entries.map((entry) => entry.path); + + expect(paths).toContain("src"); + expect(paths).toContain("src/keep.ts"); + expect(paths.some((entryPath) => entryPath.startsWith(".convex/"))).toBe(false); + }), + ); + + it.effect("excludes .convex in non-git workspaces", () => + Effect.gen(function* () { + const cwd = yield* makeTempDir({ prefix: "t3code-workspace-non-git-convex-" }); + yield* writeTextFile(cwd, ".convex/local-storage/data.json", "{}"); + yield* writeTextFile(cwd, "src/keep.ts", "export {};"); + + const result = yield* searchWorkspaceEntries({ cwd, query: "", limit: 100 }); + const paths = result.entries.map((entry) => entry.path); + + expect(paths).toContain("src"); + expect(paths).toContain("src/keep.ts"); + expect(paths.some((entryPath) => entryPath.startsWith(".convex/"))).toBe(false); + }), + ); + + it.effect("deduplicates concurrent index builds for the same cwd", () => + Effect.gen(function* () { + const cwd = yield* makeTempDir({ prefix: "t3code-workspace-concurrent-build-" }); + yield* writeTextFile(cwd, "src/components/Composer.tsx"); + + let rootReadCount = 0; + const originalReaddir = fsPromises.readdir.bind(fsPromises); + vi.spyOn(fsPromises, "readdir").mockImplementation((async ( + ...args: Parameters + ) => { + if (args[0] === cwd) { + rootReadCount += 1; + await new Promise((resolve) => setTimeout(resolve, 20)); + } + return originalReaddir(...args); + }) as typeof fsPromises.readdir); + + yield* Effect.all( + [ + searchWorkspaceEntries({ cwd, query: "", limit: 100 }), + searchWorkspaceEntries({ cwd, query: "comp", limit: 100 }), + searchWorkspaceEntries({ cwd, query: "src", limit: 100 }), + ], + { concurrency: "unbounded" }, + ); + + expect(rootReadCount).toBe(1); + }), + ); + + it.effect("limits concurrent directory reads while walking the filesystem", () => + Effect.gen(function* () { + const cwd = yield* makeTempDir({ prefix: "t3code-workspace-read-concurrency-" }); + yield* Effect.forEach( + Array.from({ length: 80 }, (_, index) => index), + (index) => writeTextFile(cwd, `group-${index}/entry-${index}.ts`, "export {};"), + { discard: true }, + ); + + let activeReads = 0; + let peakReads = 0; + const originalReaddir = fsPromises.readdir.bind(fsPromises); + vi.spyOn(fsPromises, "readdir").mockImplementation((async ( + ...args: Parameters + ) => { + const target = args[0]; + if (typeof target === "string" && target.startsWith(cwd)) { + activeReads += 1; + peakReads = Math.max(peakReads, activeReads); + await new Promise((resolve) => setTimeout(resolve, 4)); + try { + return await originalReaddir(...args); + } finally { + activeReads -= 1; + } + } + return originalReaddir(...args); + }) as typeof fsPromises.readdir); + + yield* searchWorkspaceEntries({ cwd, query: "", limit: 200 }); + + expect(peakReads).toBeLessThanOrEqual(32); + }), + ); + }); +}); diff --git a/apps/server/src/workspace/Layers/WorkspaceEntries.ts b/apps/server/src/workspace/Layers/WorkspaceEntries.ts new file mode 100644 index 0000000000..c64960593c --- /dev/null +++ b/apps/server/src/workspace/Layers/WorkspaceEntries.ts @@ -0,0 +1,478 @@ +import fsPromises from "node:fs/promises"; +import type { Dirent } from "node:fs"; + +import { Cache, Duration, Effect, Exit, Layer, Option, Path } from "effect"; + +import { type ProjectEntry } from "@t3tools/contracts"; + +import { GitCore } from "../../git/Services/GitCore.ts"; +import { + WorkspaceEntries, + WorkspaceEntriesError, + type WorkspaceEntriesShape, +} from "../Services/WorkspaceEntries.ts"; + +const WORKSPACE_CACHE_TTL_MS = 15_000; +const WORKSPACE_CACHE_MAX_KEYS = 4; +const WORKSPACE_INDEX_MAX_ENTRIES = 25_000; +const WORKSPACE_SCAN_READDIR_CONCURRENCY = 32; +const IGNORED_DIRECTORY_NAMES = new Set([ + ".git", + ".convex", + "node_modules", + ".next", + ".turbo", + "dist", + "build", + "out", + ".cache", +]); + +interface WorkspaceIndex { + scannedAt: number; + entries: SearchableWorkspaceEntry[]; + truncated: boolean; +} + +interface SearchableWorkspaceEntry extends ProjectEntry { + normalizedPath: string; + normalizedName: string; +} + +interface RankedWorkspaceEntry { + entry: SearchableWorkspaceEntry; + score: number; +} + +function toPosixPath(input: string): string { + return input.replaceAll("\\", "/"); +} + +function parentPathOf(input: string): string | undefined { + const separatorIndex = input.lastIndexOf("/"); + if (separatorIndex === -1) { + return undefined; + } + return input.slice(0, separatorIndex); +} + +function basenameOf(input: string): string { + const separatorIndex = input.lastIndexOf("/"); + if (separatorIndex === -1) { + return input; + } + return input.slice(separatorIndex + 1); +} + +function toSearchableWorkspaceEntry(entry: ProjectEntry): SearchableWorkspaceEntry { + const normalizedPath = entry.path.toLowerCase(); + return { + ...entry, + normalizedPath, + normalizedName: basenameOf(normalizedPath), + }; +} + +function normalizeQuery(input: string): string { + return input + .trim() + .replace(/^[@./]+/, "") + .toLowerCase(); +} + +function scoreSubsequenceMatch(value: string, query: string): number | null { + if (!query) return 0; + + let queryIndex = 0; + let firstMatchIndex = -1; + let previousMatchIndex = -1; + let gapPenalty = 0; + + for (let valueIndex = 0; valueIndex < value.length; valueIndex += 1) { + if (value[valueIndex] !== query[queryIndex]) { + continue; + } + + if (firstMatchIndex === -1) { + firstMatchIndex = valueIndex; + } + if (previousMatchIndex !== -1) { + gapPenalty += valueIndex - previousMatchIndex - 1; + } + + previousMatchIndex = valueIndex; + queryIndex += 1; + if (queryIndex === query.length) { + const spanPenalty = valueIndex - firstMatchIndex + 1 - query.length; + const lengthPenalty = Math.min(64, value.length - query.length); + return firstMatchIndex * 2 + gapPenalty * 3 + spanPenalty + lengthPenalty; + } + } + + return null; +} + +function scoreEntry(entry: SearchableWorkspaceEntry, query: string): number | null { + if (!query) { + return entry.kind === "directory" ? 0 : 1; + } + + const { normalizedPath, normalizedName } = entry; + + if (normalizedName === query) return 0; + if (normalizedPath === query) return 1; + if (normalizedName.startsWith(query)) return 2; + if (normalizedPath.startsWith(query)) return 3; + if (normalizedPath.includes(`/${query}`)) return 4; + if (normalizedName.includes(query)) return 5; + if (normalizedPath.includes(query)) return 6; + + const nameFuzzyScore = scoreSubsequenceMatch(normalizedName, query); + if (nameFuzzyScore !== null) { + return 100 + nameFuzzyScore; + } + + const pathFuzzyScore = scoreSubsequenceMatch(normalizedPath, query); + if (pathFuzzyScore !== null) { + return 200 + pathFuzzyScore; + } + + return null; +} + +function compareRankedWorkspaceEntries( + left: RankedWorkspaceEntry, + right: RankedWorkspaceEntry, +): number { + const scoreDelta = left.score - right.score; + if (scoreDelta !== 0) return scoreDelta; + return left.entry.path.localeCompare(right.entry.path); +} + +function findInsertionIndex( + rankedEntries: RankedWorkspaceEntry[], + candidate: RankedWorkspaceEntry, +): number { + let low = 0; + let high = rankedEntries.length; + + while (low < high) { + const middle = low + Math.floor((high - low) / 2); + const current = rankedEntries[middle]; + if (!current) { + break; + } + + if (compareRankedWorkspaceEntries(candidate, current) < 0) { + high = middle; + } else { + low = middle + 1; + } + } + + return low; +} + +function insertRankedEntry( + rankedEntries: RankedWorkspaceEntry[], + candidate: RankedWorkspaceEntry, + limit: number, +): void { + if (limit <= 0) { + return; + } + + const insertionIndex = findInsertionIndex(rankedEntries, candidate); + if (rankedEntries.length < limit) { + rankedEntries.splice(insertionIndex, 0, candidate); + return; + } + + if (insertionIndex >= limit) { + return; + } + + rankedEntries.splice(insertionIndex, 0, candidate); + rankedEntries.pop(); +} + +function isPathInIgnoredDirectory(relativePath: string): boolean { + const firstSegment = relativePath.split("/")[0]; + if (!firstSegment) return false; + return IGNORED_DIRECTORY_NAMES.has(firstSegment); +} + +function directoryAncestorsOf(relativePath: string): string[] { + const segments = relativePath.split("/").filter((segment) => segment.length > 0); + if (segments.length <= 1) return []; + + const directories: string[] = []; + for (let index = 1; index < segments.length; index += 1) { + directories.push(segments.slice(0, index).join("/")); + } + return directories; +} + +const processErrorDetail = (cause: unknown): string => + cause instanceof Error ? cause.message : String(cause); + +export const makeWorkspaceEntries = Effect.gen(function* () { + const path = yield* Path.Path; + const gitOption = yield* Effect.serviceOption(GitCore); + + const isInsideGitWorkTree = (cwd: string): Effect.Effect => + Option.match(gitOption, { + onSome: (git) => git.isInsideWorkTree(cwd).pipe(Effect.catch(() => Effect.succeed(false))), + onNone: () => Effect.succeed(false), + }); + + const filterGitIgnoredPaths = ( + cwd: string, + relativePaths: string[], + ): Effect.Effect => + Option.match(gitOption, { + onSome: (git) => + git.filterIgnoredPaths(cwd, relativePaths).pipe( + Effect.map((paths) => [...paths]), + Effect.catch(() => Effect.succeed(relativePaths)), + ), + onNone: () => Effect.succeed(relativePaths), + }); + + const buildWorkspaceIndexFromGit = Effect.fn("WorkspaceEntries.buildWorkspaceIndexFromGit")( + function* (cwd: string) { + if (Option.isNone(gitOption)) { + return null; + } + if (!(yield* isInsideGitWorkTree(cwd))) { + return null; + } + + const listedFiles = yield* gitOption.value + .listWorkspaceFiles(cwd) + .pipe(Effect.catch(() => Effect.succeed(null))); + + if (!listedFiles) { + return null; + } + + const listedPaths = [...listedFiles.paths] + .map((entry) => toPosixPath(entry)) + .filter((entry) => entry.length > 0 && !isPathInIgnoredDirectory(entry)); + const filePaths = yield* filterGitIgnoredPaths(cwd, listedPaths); + + const directorySet = new Set(); + for (const filePath of filePaths) { + for (const directoryPath of directoryAncestorsOf(filePath)) { + if (!isPathInIgnoredDirectory(directoryPath)) { + directorySet.add(directoryPath); + } + } + } + + const directoryEntries = [...directorySet] + .toSorted((left, right) => left.localeCompare(right)) + .map( + (directoryPath): ProjectEntry => ({ + path: directoryPath, + kind: "directory", + parentPath: parentPathOf(directoryPath), + }), + ) + .map(toSearchableWorkspaceEntry); + const fileEntries = [...new Set(filePaths)] + .toSorted((left, right) => left.localeCompare(right)) + .map( + (filePath): ProjectEntry => ({ + path: filePath, + kind: "file", + parentPath: parentPathOf(filePath), + }), + ) + .map(toSearchableWorkspaceEntry); + + const entries = [...directoryEntries, ...fileEntries]; + return { + scannedAt: Date.now(), + entries: entries.slice(0, WORKSPACE_INDEX_MAX_ENTRIES), + truncated: listedFiles.truncated || entries.length > WORKSPACE_INDEX_MAX_ENTRIES, + }; + }, + ); + + const readDirectoryEntries = Effect.fn("WorkspaceEntries.readDirectoryEntries")(function* ( + cwd: string, + relativeDir: string, + ): Effect.fn.Return< + { readonly relativeDir: string; readonly dirents: Dirent[] | null }, + WorkspaceEntriesError + > { + return yield* Effect.tryPromise({ + try: async () => { + const absoluteDir = relativeDir ? path.join(cwd, relativeDir) : cwd; + const dirents = await fsPromises.readdir(absoluteDir, { withFileTypes: true }); + return { relativeDir, dirents }; + }, + catch: (cause) => + new WorkspaceEntriesError({ + cwd, + operation: "workspaceEntries.readDirectoryEntries", + detail: processErrorDetail(cause), + cause, + }), + }).pipe( + Effect.catchIf( + () => relativeDir.length > 0, + () => Effect.succeed({ relativeDir, dirents: null }), + ), + ); + }); + + const buildWorkspaceIndexFromFilesystem = Effect.fn( + "WorkspaceEntries.buildWorkspaceIndexFromFilesystem", + )(function* (cwd: string): Effect.fn.Return { + const shouldFilterWithGitIgnore = yield* isInsideGitWorkTree(cwd); + + let pendingDirectories: string[] = [""]; + const entries: SearchableWorkspaceEntry[] = []; + let truncated = false; + + while (pendingDirectories.length > 0 && !truncated) { + const currentDirectories = pendingDirectories; + pendingDirectories = []; + + const directoryEntries = yield* Effect.forEach( + currentDirectories, + (relativeDir) => readDirectoryEntries(cwd, relativeDir), + { concurrency: WORKSPACE_SCAN_READDIR_CONCURRENCY }, + ); + + const candidateEntriesByDirectory = directoryEntries.map((directoryEntry) => { + const { relativeDir, dirents } = directoryEntry; + if (!dirents) return [] as Array<{ dirent: Dirent; relativePath: string }>; + + dirents.sort((left, right) => left.name.localeCompare(right.name)); + const candidates: Array<{ dirent: Dirent; relativePath: string }> = []; + for (const dirent of dirents) { + if (!dirent.name || dirent.name === "." || dirent.name === "..") { + continue; + } + if (dirent.isDirectory() && IGNORED_DIRECTORY_NAMES.has(dirent.name)) { + continue; + } + if (!dirent.isDirectory() && !dirent.isFile()) { + continue; + } + + const relativePath = toPosixPath( + relativeDir ? path.join(relativeDir, dirent.name) : dirent.name, + ); + if (isPathInIgnoredDirectory(relativePath)) { + continue; + } + candidates.push({ dirent, relativePath }); + } + return candidates; + }); + + const candidatePaths = candidateEntriesByDirectory.flatMap((candidateEntries) => + candidateEntries.map((entry) => entry.relativePath), + ); + const allowedPathSet = shouldFilterWithGitIgnore + ? new Set(yield* filterGitIgnoredPaths(cwd, candidatePaths)) + : null; + + for (const candidateEntries of candidateEntriesByDirectory) { + for (const candidate of candidateEntries) { + if (allowedPathSet && !allowedPathSet.has(candidate.relativePath)) { + continue; + } + + const entry = toSearchableWorkspaceEntry({ + path: candidate.relativePath, + kind: candidate.dirent.isDirectory() ? "directory" : "file", + parentPath: parentPathOf(candidate.relativePath), + }); + entries.push(entry); + + if (candidate.dirent.isDirectory()) { + pendingDirectories.push(candidate.relativePath); + } + + if (entries.length >= WORKSPACE_INDEX_MAX_ENTRIES) { + truncated = true; + break; + } + } + + if (truncated) { + break; + } + } + } + + return { + scannedAt: Date.now(), + entries, + truncated, + }; + }); + + const buildWorkspaceIndex = Effect.fn("WorkspaceEntries.buildWorkspaceIndex")(function* ( + cwd: string, + ): Effect.fn.Return { + const gitIndexed = yield* buildWorkspaceIndexFromGit(cwd); + if (gitIndexed) { + return gitIndexed; + } + return yield* buildWorkspaceIndexFromFilesystem(cwd); + }); + + const workspaceIndexCache = yield* Cache.makeWith({ + capacity: WORKSPACE_CACHE_MAX_KEYS, + lookup: buildWorkspaceIndex, + timeToLive: (exit) => + Exit.isSuccess(exit) ? Duration.millis(WORKSPACE_CACHE_TTL_MS) : Duration.zero, + }); + + const invalidate: WorkspaceEntriesShape["invalidate"] = Effect.fn("WorkspaceEntries.invalidate")( + function* (cwd) { + return yield* Cache.invalidate(workspaceIndexCache, cwd); + }, + ); + + const search: WorkspaceEntriesShape["search"] = Effect.fn("WorkspaceEntries.search")( + function* (input) { + return yield* Cache.get(workspaceIndexCache, input.cwd).pipe( + Effect.map((index) => { + const normalizedQuery = normalizeQuery(input.query); + const limit = Math.max(0, Math.floor(input.limit)); + const rankedEntries: RankedWorkspaceEntry[] = []; + let matchedEntryCount = 0; + + for (const entry of index.entries) { + const score = scoreEntry(entry, normalizedQuery); + if (score === null) { + continue; + } + + matchedEntryCount += 1; + insertRankedEntry(rankedEntries, { entry, score }, limit); + } + + return { + entries: rankedEntries.map((candidate) => candidate.entry), + truncated: index.truncated || matchedEntryCount > limit, + }; + }), + ); + }, + ); + + return { + invalidate, + search, + } satisfies WorkspaceEntriesShape; +}); + +export const WorkspaceEntriesLive = Layer.effect(WorkspaceEntries, makeWorkspaceEntries); diff --git a/apps/server/src/workspace/Layers/WorkspaceFileSystem.test.ts b/apps/server/src/workspace/Layers/WorkspaceFileSystem.test.ts new file mode 100644 index 0000000000..9ea8835ba3 --- /dev/null +++ b/apps/server/src/workspace/Layers/WorkspaceFileSystem.test.ts @@ -0,0 +1,135 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { it, describe, expect } from "@effect/vitest"; +import { Effect, FileSystem, Layer, Path } from "effect"; + +import { ServerConfig } from "../../config.ts"; +import { GitCoreLive } from "../../git/Layers/GitCore.ts"; +import { WorkspaceEntries } from "../Services/WorkspaceEntries.ts"; +import { WorkspaceFileSystem } from "../Services/WorkspaceFileSystem.ts"; +import { WorkspaceEntriesLive } from "./WorkspaceEntries.ts"; +import { WorkspaceFileSystemLive } from "./WorkspaceFileSystem.ts"; +import { WorkspacePathsLive } from "./WorkspacePaths.ts"; + +const ProjectLayer = WorkspaceFileSystemLive.pipe( + Layer.provide(WorkspacePathsLive), + Layer.provide(WorkspaceEntriesLive), +); + +const TestLayer = Layer.empty.pipe( + Layer.provideMerge(ProjectLayer), + Layer.provideMerge(WorkspaceEntriesLive), + Layer.provideMerge(GitCoreLive), + Layer.provide( + ServerConfig.layerTest(process.cwd(), { + prefix: "t3-workspace-files-test-", + }), + ), + Layer.provideMerge(NodeServices.layer), +); + +const makeTempDir = Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + return yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3code-workspace-files-", + }); +}); + +const writeTextFile = Effect.fn("writeTextFile")(function* ( + cwd: string, + relativePath: string, + contents = "", +) { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const absolutePath = path.join(cwd, relativePath); + yield* fileSystem + .makeDirectory(path.dirname(absolutePath), { recursive: true }) + .pipe(Effect.orDie); + yield* fileSystem.writeFileString(absolutePath, contents).pipe(Effect.orDie); +}); + +it.layer(TestLayer)("WorkspaceFileSystemLive", (it) => { + describe("writeFile", () => { + it.effect("writes files relative to the workspace root", () => + Effect.gen(function* () { + const workspaceFileSystem = yield* WorkspaceFileSystem; + const cwd = yield* makeTempDir; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const result = yield* workspaceFileSystem.writeFile({ + cwd, + relativePath: "plans/effect-rpc.md", + contents: "# Plan\n", + }); + const saved = yield* fileSystem + .readFileString(path.join(cwd, "plans/effect-rpc.md")) + .pipe(Effect.orDie); + + expect(result).toEqual({ relativePath: "plans/effect-rpc.md" }); + expect(saved).toBe("# Plan\n"); + }), + ); + + it.effect("invalidates workspace entry search cache after writes", () => + Effect.gen(function* () { + const workspaceEntries = yield* WorkspaceEntries; + const workspaceFileSystem = yield* WorkspaceFileSystem; + const cwd = yield* makeTempDir; + yield* writeTextFile(cwd, "src/existing.ts", "export {};\n"); + + const beforeWrite = yield* workspaceEntries.search({ + cwd, + query: "rpc", + limit: 10, + }); + expect(beforeWrite).toEqual({ + entries: [], + truncated: false, + }); + + yield* workspaceFileSystem.writeFile({ + cwd, + relativePath: "plans/effect-rpc.md", + contents: "# Plan\n", + }); + + const afterWrite = yield* workspaceEntries.search({ + cwd, + query: "rpc", + limit: 10, + }); + expect(afterWrite.entries).toEqual( + expect.arrayContaining([expect.objectContaining({ path: "plans/effect-rpc.md" })]), + ); + expect(afterWrite.truncated).toBe(false); + }), + ); + + it.effect("rejects writes outside the workspace root", () => + Effect.gen(function* () { + const workspaceFileSystem = yield* WorkspaceFileSystem; + const cwd = yield* makeTempDir; + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + + const error = yield* workspaceFileSystem + .writeFile({ + cwd, + relativePath: "../escape.md", + contents: "# nope\n", + }) + .pipe(Effect.flip); + + expect(error.message).toContain( + "Workspace file path must be relative to the project root: ../escape.md", + ); + + const escapedPath = path.resolve(cwd, "..", "escape.md"); + const escapedStat = yield* fileSystem + .stat(escapedPath) + .pipe(Effect.catch(() => Effect.succeed(null))); + expect(escapedStat).toBeNull(); + }), + ); + }); +}); diff --git a/apps/server/src/workspace/Layers/WorkspaceFileSystem.ts b/apps/server/src/workspace/Layers/WorkspaceFileSystem.ts new file mode 100644 index 0000000000..84e5d9c6d1 --- /dev/null +++ b/apps/server/src/workspace/Layers/WorkspaceFileSystem.ts @@ -0,0 +1,55 @@ +import { Effect, FileSystem, Layer, Path } from "effect"; + +import { + WorkspaceFileSystem, + WorkspaceFileSystemError, + type WorkspaceFileSystemShape, +} from "../Services/WorkspaceFileSystem.ts"; +import { WorkspaceEntries } from "../Services/WorkspaceEntries.ts"; +import { WorkspacePaths } from "../Services/WorkspacePaths.ts"; + +export const makeWorkspaceFileSystem = Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const workspacePaths = yield* WorkspacePaths; + const workspaceEntries = yield* WorkspaceEntries; + + const writeFile: WorkspaceFileSystemShape["writeFile"] = Effect.fn( + "WorkspaceFileSystem.writeFile", + )(function* (input) { + const target = yield* workspacePaths.resolveRelativePathWithinRoot({ + workspaceRoot: input.cwd, + relativePath: input.relativePath, + }); + + yield* fileSystem.makeDirectory(path.dirname(target.absolutePath), { recursive: true }).pipe( + Effect.mapError( + (cause) => + new WorkspaceFileSystemError({ + cwd: input.cwd, + relativePath: input.relativePath, + operation: "workspaceFileSystem.makeDirectory", + detail: cause.message, + cause, + }), + ), + ); + yield* fileSystem.writeFileString(target.absolutePath, input.contents).pipe( + Effect.mapError( + (cause) => + new WorkspaceFileSystemError({ + cwd: input.cwd, + relativePath: input.relativePath, + operation: "workspaceFileSystem.writeFile", + detail: cause.message, + cause, + }), + ), + ); + yield* workspaceEntries.invalidate(input.cwd); + return { relativePath: target.relativePath }; + }); + return { writeFile } satisfies WorkspaceFileSystemShape; +}); + +export const WorkspaceFileSystemLive = Layer.effect(WorkspaceFileSystem, makeWorkspaceFileSystem); diff --git a/apps/server/src/workspace/Layers/WorkspacePaths.test.ts b/apps/server/src/workspace/Layers/WorkspacePaths.test.ts new file mode 100644 index 0000000000..d02a5929d2 --- /dev/null +++ b/apps/server/src/workspace/Layers/WorkspacePaths.test.ts @@ -0,0 +1,113 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { it, describe, expect } from "@effect/vitest"; +import { Effect, FileSystem, Layer, Path } from "effect"; + +import { WorkspacePaths } from "../Services/WorkspacePaths.ts"; +import { WorkspacePathsLive } from "./WorkspacePaths.ts"; + +const TestLayer = Layer.empty.pipe( + Layer.provideMerge(WorkspacePathsLive), + Layer.provideMerge(NodeServices.layer), +); + +const makeTempDir = Effect.fn("makeTempDir")(function* () { + const fileSystem = yield* FileSystem.FileSystem; + return yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3code-project-paths-", + }); +}); + +const writeTextFile = Effect.fn("writeTextFile")(function* ( + cwd: string, + relativePath: string, + contents = "", +) { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const absolutePath = path.join(cwd, relativePath); + yield* fileSystem + .makeDirectory(path.dirname(absolutePath), { recursive: true }) + .pipe(Effect.orDie); + yield* fileSystem.writeFileString(absolutePath, contents).pipe(Effect.orDie); +}); + +it.layer(TestLayer)("WorkspacePathsLive", (it) => { + describe("normalizeWorkspaceRoot", () => { + it.effect("resolves an existing directory", () => + Effect.gen(function* () { + const workspacePaths = yield* WorkspacePaths; + const cwd = yield* makeTempDir(); + + const resolved = yield* workspacePaths.normalizeWorkspaceRoot(cwd); + + expect(resolved).toBe(cwd); + }), + ); + + it.effect("rejects missing directories", () => + Effect.gen(function* () { + const workspacePaths = yield* WorkspacePaths; + const cwd = yield* makeTempDir(); + const path = yield* Path.Path; + + const error = yield* workspacePaths + .normalizeWorkspaceRoot(path.join(cwd, "missing")) + .pipe(Effect.flip); + + expect(error.message).toContain("Workspace root does not exist:"); + }), + ); + + it.effect("rejects file paths", () => + Effect.gen(function* () { + const workspacePaths = yield* WorkspacePaths; + const cwd = yield* makeTempDir(); + const path = yield* Path.Path; + const filePath = path.join(cwd, "README.md"); + yield* writeTextFile(cwd, "README.md", "# hi\n"); + + const error = yield* workspacePaths.normalizeWorkspaceRoot(filePath).pipe(Effect.flip); + + expect(error.message).toContain("Workspace root is not a directory:"); + }), + ); + }); + + describe("resolveRelativePathWithinRoot", () => { + it.effect("resolves relative paths inside the workspace root", () => + Effect.gen(function* () { + const workspacePaths = yield* WorkspacePaths; + const cwd = yield* makeTempDir(); + const path = yield* Path.Path; + + const resolved = yield* workspacePaths.resolveRelativePathWithinRoot({ + workspaceRoot: cwd, + relativePath: "plans/effect-rpc.md", + }); + + expect(resolved).toEqual({ + absolutePath: path.join(cwd, "plans/effect-rpc.md"), + relativePath: "plans/effect-rpc.md", + }); + }), + ); + + it.effect("rejects paths that escape the workspace root", () => + Effect.gen(function* () { + const workspacePaths = yield* WorkspacePaths; + const cwd = yield* makeTempDir(); + + const error = yield* workspacePaths + .resolveRelativePathWithinRoot({ + workspaceRoot: cwd, + relativePath: "../escape.md", + }) + .pipe(Effect.flip); + + expect(error.message).toContain( + "Workspace file path must be relative to the project root: ../escape.md", + ); + }), + ); + }); +}); diff --git a/apps/server/src/workspace/Layers/WorkspacePaths.ts b/apps/server/src/workspace/Layers/WorkspacePaths.ts new file mode 100644 index 0000000000..fa7a90cf07 --- /dev/null +++ b/apps/server/src/workspace/Layers/WorkspacePaths.ts @@ -0,0 +1,89 @@ +import * as OS from "node:os"; +import { Effect, FileSystem, Layer, Path } from "effect"; + +import { + WorkspacePaths, + WorkspacePathOutsideRootError, + WorkspaceRootNotDirectoryError, + WorkspaceRootNotExistsError, + type WorkspacePathsShape, +} from "../Services/WorkspacePaths.ts"; + +function toPosixRelativePath(input: string): string { + return input.replaceAll("\\", "/"); +} + +function expandHomePath(input: string, path: Path.Path): string { + if (input === "~") { + return OS.homedir(); + } + if (input.startsWith("~/") || input.startsWith("~\\")) { + return path.join(OS.homedir(), input.slice(2)); + } + return input; +} + +export const makeWorkspacePaths = Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + + const normalizeWorkspaceRoot: WorkspacePathsShape["normalizeWorkspaceRoot"] = Effect.fn( + "WorkspacePaths.normalizeWorkspaceRoot", + )(function* (workspaceRoot) { + const normalizedWorkspaceRoot = path.resolve(expandHomePath(workspaceRoot.trim(), path)); + const workspaceStat = yield* fileSystem + .stat(normalizedWorkspaceRoot) + .pipe(Effect.catch(() => Effect.succeed(null))); + if (!workspaceStat) { + return yield* new WorkspaceRootNotExistsError({ + workspaceRoot, + normalizedWorkspaceRoot, + }); + } + if (workspaceStat.type !== "Directory") { + return yield* new WorkspaceRootNotDirectoryError({ + workspaceRoot, + normalizedWorkspaceRoot, + }); + } + return normalizedWorkspaceRoot; + }); + + const resolveRelativePathWithinRoot: WorkspacePathsShape["resolveRelativePathWithinRoot"] = + Effect.fn("WorkspacePaths.resolveRelativePathWithinRoot")(function* (input) { + const normalizedInputPath = input.relativePath.trim(); + if (path.isAbsolute(normalizedInputPath)) { + return yield* new WorkspacePathOutsideRootError({ + workspaceRoot: input.workspaceRoot, + relativePath: input.relativePath, + }); + } + + const absolutePath = path.resolve(input.workspaceRoot, normalizedInputPath); + const relativeToRoot = toPosixRelativePath(path.relative(input.workspaceRoot, absolutePath)); + if ( + relativeToRoot.length === 0 || + relativeToRoot === "." || + relativeToRoot.startsWith("../") || + relativeToRoot === ".." || + path.isAbsolute(relativeToRoot) + ) { + return yield* new WorkspacePathOutsideRootError({ + workspaceRoot: input.workspaceRoot, + relativePath: input.relativePath, + }); + } + + return { + absolutePath, + relativePath: relativeToRoot, + }; + }); + + return { + normalizeWorkspaceRoot, + resolveRelativePathWithinRoot, + } satisfies WorkspacePathsShape; +}); + +export const WorkspacePathsLive = Layer.effect(WorkspacePaths, makeWorkspacePaths); diff --git a/apps/server/src/workspace/Services/WorkspaceEntries.ts b/apps/server/src/workspace/Services/WorkspaceEntries.ts new file mode 100644 index 0000000000..2841b1fe2f --- /dev/null +++ b/apps/server/src/workspace/Services/WorkspaceEntries.ts @@ -0,0 +1,48 @@ +/** + * WorkspaceEntries - Effect service contract for cached workspace entry search. + * + * Owns indexed workspace entry search plus cache invalidation for workspace + * roots when the underlying filesystem changes. + * + * @module WorkspaceEntries + */ +import { Schema, ServiceMap } from "effect"; +import type { Effect } from "effect"; + +import type { ProjectSearchEntriesInput, ProjectSearchEntriesResult } from "@t3tools/contracts"; + +export class WorkspaceEntriesError extends Schema.TaggedErrorClass()( + "WorkspaceEntriesError", + { + cwd: Schema.String, + operation: Schema.String, + detail: Schema.String, + cause: Schema.optional(Schema.Defect), + }, +) {} + +/** + * WorkspaceEntriesShape - Service API for workspace entry search and cache + * invalidation. + */ +export interface WorkspaceEntriesShape { + /** + * Search indexed workspace entries for files and directories matching the + * provided query. + */ + readonly search: ( + input: ProjectSearchEntriesInput, + ) => Effect.Effect; + + /** + * Drop any cached workspace entries for the given workspace root. + */ + readonly invalidate: (cwd: string) => Effect.Effect; +} + +/** + * WorkspaceEntries - Service tag for cached workspace entry search. + */ +export class WorkspaceEntries extends ServiceMap.Service()( + "t3/workspace/Services/WorkspaceEntries", +) {} diff --git a/apps/server/src/workspace/Services/WorkspaceFileSystem.ts b/apps/server/src/workspace/Services/WorkspaceFileSystem.ts new file mode 100644 index 0000000000..85db1514a3 --- /dev/null +++ b/apps/server/src/workspace/Services/WorkspaceFileSystem.ts @@ -0,0 +1,50 @@ +/** + * WorkspaceFileSystem - Effect service contract for workspace file mutations. + * + * Owns workspace-root-relative file write operations and their associated + * safety checks and cache invalidation hooks. + * + * @module WorkspaceFileSystem + */ +import { Schema, ServiceMap } from "effect"; +import type { Effect } from "effect"; + +import type { ProjectWriteFileInput, ProjectWriteFileResult } from "@t3tools/contracts"; +import { WorkspacePathOutsideRootError } from "./WorkspacePaths.ts"; + +export class WorkspaceFileSystemError extends Schema.TaggedErrorClass()( + "WorkspaceFileSystemError", + { + cwd: Schema.String, + relativePath: Schema.optional(Schema.String), + operation: Schema.String, + detail: Schema.String, + cause: Schema.optional(Schema.Defect), + }, +) {} + +/** + * WorkspaceFileSystemShape - Service API for workspace-relative file operations. + */ +export interface WorkspaceFileSystemShape { + /** + * Write a file relative to the workspace root. + * + * Creates parent directories as needed and rejects paths that escape the + * workspace root. + */ + readonly writeFile: ( + input: ProjectWriteFileInput, + ) => Effect.Effect< + ProjectWriteFileResult, + WorkspaceFileSystemError | WorkspacePathOutsideRootError + >; +} + +/** + * WorkspaceFileSystem - Service tag for workspace file operations. + */ +export class WorkspaceFileSystem extends ServiceMap.Service< + WorkspaceFileSystem, + WorkspaceFileSystemShape +>()("t3/workspace/Services/WorkspaceFileSystem") {} diff --git a/apps/server/src/workspace/Services/WorkspacePaths.ts b/apps/server/src/workspace/Services/WorkspacePaths.ts new file mode 100644 index 0000000000..ad6d9cd3e5 --- /dev/null +++ b/apps/server/src/workspace/Services/WorkspacePaths.ts @@ -0,0 +1,85 @@ +/** + * WorkspacePaths - Effect service contract for workspace path handling. + * + * Owns normalization and validation of workspace roots plus safe resolution of + * workspace-root-relative paths. + * + * @module WorkspacePaths + */ +import { Schema, ServiceMap } from "effect"; +import type { Effect } from "effect"; + +export class WorkspaceRootNotExistsError extends Schema.TaggedErrorClass()( + "WorkspaceRootNotExistsError", + { + workspaceRoot: Schema.String, + normalizedWorkspaceRoot: Schema.String, + }, +) { + override get message(): string { + return `Workspace root does not exist: ${this.normalizedWorkspaceRoot}`; + } +} + +export class WorkspaceRootNotDirectoryError extends Schema.TaggedErrorClass()( + "WorkspaceRootNotDirectoryError", + { + workspaceRoot: Schema.String, + normalizedWorkspaceRoot: Schema.String, + }, +) { + override get message(): string { + return `Workspace root is not a directory: ${this.normalizedWorkspaceRoot}`; + } +} + +export class WorkspacePathOutsideRootError extends Schema.TaggedErrorClass()( + "WorkspacePathOutsideRootError", + { + workspaceRoot: Schema.String, + relativePath: Schema.String, + }, +) { + override get message(): string { + return `Workspace file path must be relative to the project root: ${this.relativePath}`; + } +} + +export const WorkspacePathsError = Schema.Union([ + WorkspaceRootNotExistsError, + WorkspaceRootNotDirectoryError, + WorkspacePathOutsideRootError, +]); +export type WorkspacePathsError = typeof WorkspacePathsError.Type; + +/** + * WorkspacePathsShape - Service API for workspace path normalization and guards. + */ +export interface WorkspacePathsShape { + /** + * Normalize a user-provided workspace root and verify it exists as a directory. + */ + readonly normalizeWorkspaceRoot: ( + workspaceRoot: string, + ) => Effect.Effect; + + /** + * Resolve a relative path within a validated workspace root. + * + * Rejects absolute paths and traversal attempts outside the workspace root. + */ + readonly resolveRelativePathWithinRoot: (input: { + workspaceRoot: string; + relativePath: string; + }) => Effect.Effect< + { absolutePath: string; relativePath: string }, + WorkspacePathOutsideRootError + >; +} + +/** + * WorkspacePaths - Service tag for workspace path normalization and resolution. + */ +export class WorkspacePaths extends ServiceMap.Service()( + "t3/workspace/Services/WorkspacePaths", +) {} diff --git a/apps/server/src/workspaceEntries.chunking.test.ts b/apps/server/src/workspaceEntries.chunking.test.ts deleted file mode 100644 index f978c5233e..0000000000 --- a/apps/server/src/workspaceEntries.chunking.test.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { assert, beforeEach, describe, it, vi } from "vitest"; -import type { ProcessRunOptions, ProcessRunResult } from "./processRunner"; - -const { runProcessMock } = vi.hoisted(() => ({ - runProcessMock: - vi.fn< - ( - command: string, - args: readonly string[], - options?: ProcessRunOptions, - ) => Promise - >(), -})); - -vi.mock("./processRunner", () => ({ - runProcess: runProcessMock, -})); - -function processResult( - overrides: Partial & Pick, -): ProcessRunResult { - return { - stdout: overrides.stdout, - code: overrides.code, - stderr: overrides.stderr ?? "", - signal: overrides.signal ?? null, - timedOut: overrides.timedOut ?? false, - stdoutTruncated: overrides.stdoutTruncated ?? false, - stderrTruncated: overrides.stderrTruncated ?? false, - }; -} - -describe("searchWorkspaceEntries git-ignore chunking", () => { - beforeEach(() => { - runProcessMock.mockReset(); - vi.resetModules(); - }); - - it("chunks git check-ignore stdin to avoid building giant strings", async () => { - const ignoredPaths = Array.from( - { length: 5000 }, - (_, index) => `ignored/${index.toString().padStart(5, "0")}/${"x".repeat(80)}.ts`, - ); - const keptPaths = ["src/keep.ts", "docs/readme.md"]; - const listedPaths = [...ignoredPaths, ...keptPaths]; - let checkIgnoreCalls = 0; - - runProcessMock.mockImplementation(async (_command, args, options) => { - if (args[0] === "rev-parse") { - return processResult({ code: 0, stdout: "true\n" }); - } - - if (args[0] === "ls-files") { - return processResult({ code: 0, stdout: `${listedPaths.join("\0")}\0` }); - } - - if (args[0] === "check-ignore") { - checkIgnoreCalls += 1; - const chunkPaths = (options?.stdin ?? "").split("\0").filter((value) => value.length > 0); - const chunkIgnored = chunkPaths.filter((value) => value.startsWith("ignored/")); - return processResult({ - code: chunkIgnored.length > 0 ? 0 : 1, - stdout: chunkIgnored.length > 0 ? `${chunkIgnored.join("\0")}\0` : "", - }); - } - - throw new Error(`Unexpected command: git ${args.join(" ")}`); - }); - - const { searchWorkspaceEntries } = await import("./workspaceEntries"); - const result = await searchWorkspaceEntries({ - cwd: "/virtual/workspace", - query: "", - limit: 100, - }); - - assert.isAbove(checkIgnoreCalls, 1); - assert.isFalse(result.entries.some((entry) => entry.path.startsWith("ignored/"))); - assert.isTrue(result.entries.some((entry) => entry.path === "src/keep.ts")); - }); -}); diff --git a/apps/server/src/workspaceEntries.test.ts b/apps/server/src/workspaceEntries.test.ts deleted file mode 100644 index d867ad910d..0000000000 --- a/apps/server/src/workspaceEntries.test.ts +++ /dev/null @@ -1,202 +0,0 @@ -import fs from "node:fs"; -import fsPromises from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; -import { spawnSync } from "node:child_process"; - -import { afterEach, assert, describe, it, vi } from "vitest"; - -import { searchWorkspaceEntries } from "./workspaceEntries"; - -const tempDirs: string[] = []; - -function makeTempDir(prefix: string): string { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); - tempDirs.push(dir); - return dir; -} - -function writeFile(cwd: string, relativePath: string, contents = ""): void { - const absolutePath = path.join(cwd, relativePath); - fs.mkdirSync(path.dirname(absolutePath), { recursive: true }); - fs.writeFileSync(absolutePath, contents, "utf8"); -} - -function runGit(cwd: string, args: string[]): void { - const result = spawnSync("git", args, { cwd, encoding: "utf8" }); - if (result.status !== 0) { - throw new Error(result.stderr || `git ${args.join(" ")} failed`); - } -} - -describe("searchWorkspaceEntries", () => { - afterEach(() => { - vi.restoreAllMocks(); - for (const dir of tempDirs.splice(0, tempDirs.length)) { - fs.rmSync(dir, { recursive: true, force: true }); - } - }); - - it("returns files and directories relative to cwd", async () => { - const cwd = makeTempDir("t3code-workspace-entries-"); - writeFile(cwd, "src/components/Composer.tsx"); - writeFile(cwd, "src/index.ts"); - writeFile(cwd, "README.md"); - writeFile(cwd, ".git/HEAD"); - writeFile(cwd, "node_modules/pkg/index.js"); - - const result = await searchWorkspaceEntries({ cwd, query: "", limit: 100 }); - const paths = result.entries.map((entry) => entry.path); - - assert.include(paths, "src"); - assert.include(paths, "src/components"); - assert.include(paths, "src/components/Composer.tsx"); - assert.include(paths, "README.md"); - assert.isFalse(paths.some((entryPath) => entryPath.startsWith(".git"))); - assert.isFalse(paths.some((entryPath) => entryPath.startsWith("node_modules"))); - assert.isFalse(result.truncated); - }); - - it("filters and ranks entries by query", async () => { - const cwd = makeTempDir("t3code-workspace-query-"); - writeFile(cwd, "src/components/Composer.tsx"); - writeFile(cwd, "src/components/composePrompt.ts"); - writeFile(cwd, "docs/composition.md"); - - const result = await searchWorkspaceEntries({ cwd, query: "compo", limit: 5 }); - - assert.isAbove(result.entries.length, 0); - assert.isTrue(result.entries.some((entry) => entry.path === "src/components")); - assert.isTrue(result.entries.every((entry) => entry.path.toLowerCase().includes("compo"))); - }); - - it("supports fuzzy subsequence queries for composer path search", async () => { - const cwd = makeTempDir("t3code-workspace-fuzzy-query-"); - writeFile(cwd, "src/components/Composer.tsx"); - writeFile(cwd, "src/components/composePrompt.ts"); - writeFile(cwd, "docs/composition.md"); - - const result = await searchWorkspaceEntries({ cwd, query: "cmp", limit: 10 }); - const paths = result.entries.map((entry) => entry.path); - - assert.isAbove(result.entries.length, 0); - assert.include(paths, "src/components"); - assert.include(paths, "src/components/Composer.tsx"); - }); - - it("tracks truncation without sorting every fuzzy match", async () => { - const cwd = makeTempDir("t3code-workspace-fuzzy-limit-"); - writeFile(cwd, "src/components/Composer.tsx"); - writeFile(cwd, "src/components/composePrompt.ts"); - writeFile(cwd, "docs/composition.md"); - - const result = await searchWorkspaceEntries({ cwd, query: "cmp", limit: 1 }); - - assert.lengthOf(result.entries, 1); - assert.isTrue(result.truncated); - }); - - it("excludes gitignored paths for git repositories", async () => { - const cwd = makeTempDir("t3code-workspace-gitignore-"); - runGit(cwd, ["init"]); - writeFile(cwd, ".gitignore", ".convex/\nconvex/\nignored.txt\n"); - writeFile(cwd, "src/keep.ts", "export {};"); - writeFile(cwd, "ignored.txt", "ignore me"); - writeFile(cwd, ".convex/local-storage/data.json", "{}"); - writeFile(cwd, "convex/UOoS-l/convex_local_storage/modules/data.json", "{}"); - - const result = await searchWorkspaceEntries({ cwd, query: "", limit: 100 }); - const paths = result.entries.map((entry) => entry.path); - - assert.include(paths, "src"); - assert.include(paths, "src/keep.ts"); - assert.notInclude(paths, "ignored.txt"); - assert.isFalse(paths.some((entryPath) => entryPath.startsWith(".convex/"))); - assert.isFalse(paths.some((entryPath) => entryPath.startsWith("convex/"))); - }); - - it("excludes tracked paths that match ignore rules", async () => { - const cwd = makeTempDir("t3code-workspace-tracked-gitignore-"); - runGit(cwd, ["init"]); - writeFile(cwd, ".convex/local-storage/data.json", "{}"); - writeFile(cwd, "src/keep.ts", "export {};"); - runGit(cwd, ["add", ".convex/local-storage/data.json", "src/keep.ts"]); - writeFile(cwd, ".gitignore", ".convex/\n"); - - const result = await searchWorkspaceEntries({ cwd, query: "", limit: 100 }); - const paths = result.entries.map((entry) => entry.path); - - assert.include(paths, "src"); - assert.include(paths, "src/keep.ts"); - assert.isFalse(paths.some((entryPath) => entryPath.startsWith(".convex/"))); - }); - - it("excludes .convex in non-git workspaces", async () => { - const cwd = makeTempDir("t3code-workspace-non-git-convex-"); - writeFile(cwd, ".convex/local-storage/data.json", "{}"); - writeFile(cwd, "src/keep.ts", "export {};"); - - const result = await searchWorkspaceEntries({ cwd, query: "", limit: 100 }); - const paths = result.entries.map((entry) => entry.path); - - assert.include(paths, "src"); - assert.include(paths, "src/keep.ts"); - assert.isFalse(paths.some((entryPath) => entryPath.startsWith(".convex/"))); - }); - - it("deduplicates concurrent index builds for the same cwd", async () => { - const cwd = makeTempDir("t3code-workspace-concurrent-build-"); - writeFile(cwd, "src/components/Composer.tsx"); - - let rootReadCount = 0; - const originalReaddir = fsPromises.readdir.bind(fsPromises); - vi.spyOn(fsPromises, "readdir").mockImplementation((async ( - ...args: Parameters - ) => { - if (args[0] === cwd) { - rootReadCount += 1; - await new Promise((resolve) => setTimeout(resolve, 20)); - } - return originalReaddir(...args); - }) as typeof fsPromises.readdir); - - await Promise.all([ - searchWorkspaceEntries({ cwd, query: "", limit: 100 }), - searchWorkspaceEntries({ cwd, query: "comp", limit: 100 }), - searchWorkspaceEntries({ cwd, query: "src", limit: 100 }), - ]); - - assert.equal(rootReadCount, 1); - }); - - it("limits concurrent directory reads while walking the filesystem", async () => { - const cwd = makeTempDir("t3code-workspace-read-concurrency-"); - for (let index = 0; index < 80; index += 1) { - writeFile(cwd, `group-${index}/entry-${index}.ts`, "export {};"); - } - - let activeReads = 0; - let peakReads = 0; - const originalReaddir = fsPromises.readdir.bind(fsPromises); - vi.spyOn(fsPromises, "readdir").mockImplementation((async ( - ...args: Parameters - ) => { - const target = args[0]; - if (typeof target === "string" && target.startsWith(cwd)) { - activeReads += 1; - peakReads = Math.max(peakReads, activeReads); - await new Promise((resolve) => setTimeout(resolve, 4)); - try { - return await originalReaddir(...args); - } finally { - activeReads -= 1; - } - } - return originalReaddir(...args); - }) as typeof fsPromises.readdir); - - await searchWorkspaceEntries({ cwd, query: "", limit: 200 }); - - assert.isAtMost(peakReads, 32); - }); -}); diff --git a/apps/server/src/workspaceEntries.ts b/apps/server/src/workspaceEntries.ts deleted file mode 100644 index 684b005e83..0000000000 --- a/apps/server/src/workspaceEntries.ts +++ /dev/null @@ -1,565 +0,0 @@ -import fs from "node:fs/promises"; -import type { Dirent } from "node:fs"; -import path from "node:path"; -import { runProcess } from "./processRunner"; - -import { - ProjectEntry, - ProjectSearchEntriesInput, - ProjectSearchEntriesResult, -} from "@t3tools/contracts"; - -const WORKSPACE_CACHE_TTL_MS = 15_000; -const WORKSPACE_CACHE_MAX_KEYS = 4; -const WORKSPACE_INDEX_MAX_ENTRIES = 25_000; -const WORKSPACE_SCAN_READDIR_CONCURRENCY = 32; -const GIT_CHECK_IGNORE_MAX_STDIN_BYTES = 256 * 1024; -const IGNORED_DIRECTORY_NAMES = new Set([ - ".git", - ".convex", - "node_modules", - ".next", - ".turbo", - "dist", - "build", - "out", - ".cache", -]); - -interface WorkspaceIndex { - scannedAt: number; - entries: SearchableWorkspaceEntry[]; - truncated: boolean; -} - -interface SearchableWorkspaceEntry extends ProjectEntry { - normalizedPath: string; - normalizedName: string; -} - -interface RankedWorkspaceEntry { - entry: SearchableWorkspaceEntry; - score: number; -} - -const workspaceIndexCache = new Map(); -const inFlightWorkspaceIndexBuilds = new Map>(); - -function toPosixPath(input: string): string { - return input.split(path.sep).join("/"); -} - -function parentPathOf(input: string): string | undefined { - const separatorIndex = input.lastIndexOf("/"); - if (separatorIndex === -1) { - return undefined; - } - return input.slice(0, separatorIndex); -} - -function basenameOf(input: string): string { - const separatorIndex = input.lastIndexOf("/"); - if (separatorIndex === -1) { - return input; - } - return input.slice(separatorIndex + 1); -} - -function toSearchableWorkspaceEntry(entry: ProjectEntry): SearchableWorkspaceEntry { - const normalizedPath = entry.path.toLowerCase(); - return { - ...entry, - normalizedPath, - normalizedName: basenameOf(normalizedPath), - }; -} - -function normalizeQuery(input: string): string { - return input - .trim() - .replace(/^[@./]+/, "") - .toLowerCase(); -} - -function scoreSubsequenceMatch(value: string, query: string): number | null { - if (!query) return 0; - - let queryIndex = 0; - let firstMatchIndex = -1; - let previousMatchIndex = -1; - let gapPenalty = 0; - - for (let valueIndex = 0; valueIndex < value.length; valueIndex += 1) { - if (value[valueIndex] !== query[queryIndex]) { - continue; - } - - if (firstMatchIndex === -1) { - firstMatchIndex = valueIndex; - } - if (previousMatchIndex !== -1) { - gapPenalty += valueIndex - previousMatchIndex - 1; - } - - previousMatchIndex = valueIndex; - queryIndex += 1; - if (queryIndex === query.length) { - const spanPenalty = valueIndex - firstMatchIndex + 1 - query.length; - const lengthPenalty = Math.min(64, value.length - query.length); - return firstMatchIndex * 2 + gapPenalty * 3 + spanPenalty + lengthPenalty; - } - } - - return null; -} - -function scoreEntry(entry: SearchableWorkspaceEntry, query: string): number | null { - if (!query) { - return entry.kind === "directory" ? 0 : 1; - } - - const { normalizedPath, normalizedName } = entry; - - if (normalizedName === query) return 0; - if (normalizedPath === query) return 1; - if (normalizedName.startsWith(query)) return 2; - if (normalizedPath.startsWith(query)) return 3; - if (normalizedPath.includes(`/${query}`)) return 4; - if (normalizedName.includes(query)) return 5; - if (normalizedPath.includes(query)) return 6; - - const nameFuzzyScore = scoreSubsequenceMatch(normalizedName, query); - if (nameFuzzyScore !== null) { - return 100 + nameFuzzyScore; - } - - const pathFuzzyScore = scoreSubsequenceMatch(normalizedPath, query); - if (pathFuzzyScore !== null) { - return 200 + pathFuzzyScore; - } - - return null; -} - -function compareRankedWorkspaceEntries( - left: RankedWorkspaceEntry, - right: RankedWorkspaceEntry, -): number { - const scoreDelta = left.score - right.score; - if (scoreDelta !== 0) return scoreDelta; - return left.entry.path.localeCompare(right.entry.path); -} - -function findInsertionIndex( - rankedEntries: RankedWorkspaceEntry[], - candidate: RankedWorkspaceEntry, -): number { - let low = 0; - let high = rankedEntries.length; - - while (low < high) { - const middle = low + Math.floor((high - low) / 2); - const current = rankedEntries[middle]; - if (!current) { - break; - } - - if (compareRankedWorkspaceEntries(candidate, current) < 0) { - high = middle; - } else { - low = middle + 1; - } - } - - return low; -} - -function insertRankedEntry( - rankedEntries: RankedWorkspaceEntry[], - candidate: RankedWorkspaceEntry, - limit: number, -): void { - if (limit <= 0) { - return; - } - - const insertionIndex = findInsertionIndex(rankedEntries, candidate); - if (rankedEntries.length < limit) { - rankedEntries.splice(insertionIndex, 0, candidate); - return; - } - - if (insertionIndex >= limit) { - return; - } - - rankedEntries.splice(insertionIndex, 0, candidate); - rankedEntries.pop(); -} - -function isPathInIgnoredDirectory(relativePath: string): boolean { - const firstSegment = relativePath.split("/")[0]; - if (!firstSegment) return false; - return IGNORED_DIRECTORY_NAMES.has(firstSegment); -} - -function splitNullSeparatedPaths(input: string, truncated: boolean): string[] { - const parts = input.split("\0"); - if (parts.length === 0) return []; - - // If output was truncated, the final token can be partial. - if (truncated && parts[parts.length - 1]?.length) { - parts.pop(); - } - - return parts.filter((value) => value.length > 0); -} - -function directoryAncestorsOf(relativePath: string): string[] { - const segments = relativePath.split("/").filter((segment) => segment.length > 0); - if (segments.length <= 1) return []; - const directories: string[] = []; - for (let index = 1; index < segments.length; index += 1) { - directories.push(segments.slice(0, index).join("/")); - } - return directories; -} - -async function mapWithConcurrency( - items: readonly TInput[], - concurrency: number, - mapper: (item: TInput, index: number) => Promise, -): Promise { - if (items.length === 0) { - return []; - } - - const boundedConcurrency = Math.max(1, Math.min(concurrency, items.length)); - const results = Array.from({ length: items.length }) as TOutput[]; - let nextIndex = 0; - - const workers = Array.from({ length: boundedConcurrency }, async () => { - while (nextIndex < items.length) { - const currentIndex = nextIndex; - nextIndex += 1; - results[currentIndex] = await mapper(items[currentIndex] as TInput, currentIndex); - } - }); - - await Promise.all(workers); - return results; -} - -async function isInsideGitWorkTree(cwd: string): Promise { - const insideWorkTree = await runProcess("git", ["rev-parse", "--is-inside-work-tree"], { - cwd, - allowNonZeroExit: true, - timeoutMs: 5_000, - maxBufferBytes: 4_096, - }).catch(() => null); - return Boolean( - insideWorkTree && insideWorkTree.code === 0 && insideWorkTree.stdout.trim() === "true", - ); -} - -async function filterGitIgnoredPaths(cwd: string, relativePaths: string[]): Promise { - if (relativePaths.length === 0) { - return relativePaths; - } - - const ignoredPaths = new Set(); - let chunk: string[] = []; - let chunkBytes = 0; - - const flushChunk = async (): Promise => { - if (chunk.length === 0) { - return true; - } - - const checkIgnore = await runProcess("git", ["check-ignore", "--no-index", "-z", "--stdin"], { - cwd, - allowNonZeroExit: true, - timeoutMs: 20_000, - maxBufferBytes: 16 * 1024 * 1024, - outputMode: "truncate", - stdin: `${chunk.join("\0")}\0`, - }).catch(() => null); - chunk = []; - chunkBytes = 0; - - if (!checkIgnore) { - return false; - } - - // git-check-ignore exits with 1 when no paths match. - if (checkIgnore.code !== 0 && checkIgnore.code !== 1) { - return false; - } - - const matchedIgnoredPaths = splitNullSeparatedPaths( - checkIgnore.stdout, - Boolean(checkIgnore.stdoutTruncated), - ); - for (const ignoredPath of matchedIgnoredPaths) { - ignoredPaths.add(ignoredPath); - } - return true; - }; - - for (const relativePath of relativePaths) { - const relativePathBytes = Buffer.byteLength(relativePath) + 1; - if ( - chunk.length > 0 && - chunkBytes + relativePathBytes > GIT_CHECK_IGNORE_MAX_STDIN_BYTES && - !(await flushChunk()) - ) { - return relativePaths; - } - - chunk.push(relativePath); - chunkBytes += relativePathBytes; - - if (chunkBytes >= GIT_CHECK_IGNORE_MAX_STDIN_BYTES && !(await flushChunk())) { - return relativePaths; - } - } - - if (!(await flushChunk())) { - return relativePaths; - } - - if (ignoredPaths.size === 0) { - return relativePaths; - } - - return relativePaths.filter((relativePath) => !ignoredPaths.has(relativePath)); -} - -async function buildWorkspaceIndexFromGit(cwd: string): Promise { - if (!(await isInsideGitWorkTree(cwd))) { - return null; - } - - const listedFiles = await runProcess( - "git", - ["ls-files", "--cached", "--others", "--exclude-standard", "-z"], - { - cwd, - allowNonZeroExit: true, - timeoutMs: 20_000, - maxBufferBytes: 16 * 1024 * 1024, - outputMode: "truncate", - }, - ).catch(() => null); - if (!listedFiles || listedFiles.code !== 0) { - return null; - } - - const listedPaths = splitNullSeparatedPaths( - listedFiles.stdout, - Boolean(listedFiles.stdoutTruncated), - ) - .map((entry) => toPosixPath(entry)) - .filter((entry) => entry.length > 0 && !isPathInIgnoredDirectory(entry)); - const filePaths = await filterGitIgnoredPaths(cwd, listedPaths); - - const directorySet = new Set(); - for (const filePath of filePaths) { - for (const directoryPath of directoryAncestorsOf(filePath)) { - if (!isPathInIgnoredDirectory(directoryPath)) { - directorySet.add(directoryPath); - } - } - } - - const directoryEntries = [...directorySet] - .toSorted((left, right) => left.localeCompare(right)) - .map( - (directoryPath): ProjectEntry => ({ - path: directoryPath, - kind: "directory", - parentPath: parentPathOf(directoryPath), - }), - ) - .map(toSearchableWorkspaceEntry); - const fileEntries = [...new Set(filePaths)] - .toSorted((left, right) => left.localeCompare(right)) - .map( - (filePath): ProjectEntry => ({ - path: filePath, - kind: "file", - parentPath: parentPathOf(filePath), - }), - ) - .map(toSearchableWorkspaceEntry); - - const entries = [...directoryEntries, ...fileEntries]; - return { - scannedAt: Date.now(), - entries: entries.slice(0, WORKSPACE_INDEX_MAX_ENTRIES), - truncated: Boolean(listedFiles.stdoutTruncated) || entries.length > WORKSPACE_INDEX_MAX_ENTRIES, - }; -} - -async function buildWorkspaceIndex(cwd: string): Promise { - const gitIndexed = await buildWorkspaceIndexFromGit(cwd); - if (gitIndexed) { - return gitIndexed; - } - const shouldFilterWithGitIgnore = await isInsideGitWorkTree(cwd); - - let pendingDirectories: string[] = [""]; - const entries: SearchableWorkspaceEntry[] = []; - let truncated = false; - - while (pendingDirectories.length > 0 && !truncated) { - const currentDirectories = pendingDirectories; - pendingDirectories = []; - const directoryEntries = await mapWithConcurrency( - currentDirectories, - WORKSPACE_SCAN_READDIR_CONCURRENCY, - async (relativeDir) => { - const absoluteDir = relativeDir ? path.join(cwd, relativeDir) : cwd; - try { - const dirents = await fs.readdir(absoluteDir, { withFileTypes: true }); - return { relativeDir, dirents }; - } catch (error) { - if (!relativeDir) { - throw new Error( - `Unable to scan workspace entries at '${cwd}': ${error instanceof Error ? error.message : "unknown error"}`, - { cause: error }, - ); - } - return { relativeDir, dirents: null }; - } - }, - ); - - const candidateEntriesByDirectory = directoryEntries.map((directoryEntry) => { - const { relativeDir, dirents } = directoryEntry; - if (!dirents) return [] as Array<{ dirent: Dirent; relativePath: string }>; - - dirents.sort((left, right) => left.name.localeCompare(right.name)); - const candidates: Array<{ dirent: Dirent; relativePath: string }> = []; - for (const dirent of dirents) { - if (!dirent.name || dirent.name === "." || dirent.name === "..") { - continue; - } - if (dirent.isDirectory() && IGNORED_DIRECTORY_NAMES.has(dirent.name)) { - continue; - } - if (!dirent.isDirectory() && !dirent.isFile()) { - continue; - } - - const relativePath = toPosixPath( - relativeDir ? path.join(relativeDir, dirent.name) : dirent.name, - ); - if (isPathInIgnoredDirectory(relativePath)) { - continue; - } - candidates.push({ dirent, relativePath }); - } - return candidates; - }); - - const candidatePaths = candidateEntriesByDirectory.flatMap((candidateEntries) => - candidateEntries.map((entry) => entry.relativePath), - ); - const allowedPathSet = shouldFilterWithGitIgnore - ? new Set(await filterGitIgnoredPaths(cwd, candidatePaths)) - : null; - - for (const candidateEntries of candidateEntriesByDirectory) { - for (const candidate of candidateEntries) { - if (allowedPathSet && !allowedPathSet.has(candidate.relativePath)) { - continue; - } - - const entry = toSearchableWorkspaceEntry({ - path: candidate.relativePath, - kind: candidate.dirent.isDirectory() ? "directory" : "file", - parentPath: parentPathOf(candidate.relativePath), - }); - entries.push(entry); - - if (candidate.dirent.isDirectory()) { - pendingDirectories.push(candidate.relativePath); - } - - if (entries.length >= WORKSPACE_INDEX_MAX_ENTRIES) { - truncated = true; - break; - } - } - - if (truncated) { - break; - } - } - } - - return { - scannedAt: Date.now(), - entries, - truncated, - }; -} - -async function getWorkspaceIndex(cwd: string): Promise { - const cached = workspaceIndexCache.get(cwd); - if (cached && Date.now() - cached.scannedAt < WORKSPACE_CACHE_TTL_MS) { - return cached; - } - - const inFlight = inFlightWorkspaceIndexBuilds.get(cwd); - if (inFlight) { - return inFlight; - } - - const nextPromise = buildWorkspaceIndex(cwd) - .then((next) => { - workspaceIndexCache.set(cwd, next); - while (workspaceIndexCache.size > WORKSPACE_CACHE_MAX_KEYS) { - const oldestKey = workspaceIndexCache.keys().next().value; - if (!oldestKey) break; - workspaceIndexCache.delete(oldestKey); - } - return next; - }) - .finally(() => { - inFlightWorkspaceIndexBuilds.delete(cwd); - }); - inFlightWorkspaceIndexBuilds.set(cwd, nextPromise); - return nextPromise; -} - -export function clearWorkspaceIndexCache(cwd: string): void { - workspaceIndexCache.delete(cwd); - inFlightWorkspaceIndexBuilds.delete(cwd); -} - -export async function searchWorkspaceEntries( - input: ProjectSearchEntriesInput, -): Promise { - const index = await getWorkspaceIndex(input.cwd); - const normalizedQuery = normalizeQuery(input.query); - const limit = Math.max(0, Math.floor(input.limit)); - const rankedEntries: RankedWorkspaceEntry[] = []; - let matchedEntryCount = 0; - - for (const entry of index.entries) { - const score = scoreEntry(entry, normalizedQuery); - if (score === null) { - continue; - } - - matchedEntryCount += 1; - insertRankedEntry(rankedEntries, { entry, score }, limit); - } - - return { - entries: rankedEntries.map((candidate) => candidate.entry), - truncated: index.truncated || matchedEntryCount > limit, - }; -} diff --git a/apps/server/src/wsServer.test.ts b/apps/server/src/wsServer.test.ts index 0e06650fe6..4fa281d29f 100644 --- a/apps/server/src/wsServer.test.ts +++ b/apps/server/src/wsServer.test.ts @@ -4,7 +4,17 @@ import os from "node:os"; import path from "node:path"; import * as NodeServices from "@effect/platform-node/NodeServices"; -import { Effect, Exit, Fiber, Layer, PlatformError, PubSub, Scope, Stream } from "effect"; +import { + Effect, + Exit, + Fiber, + Layer, + ManagedRuntime, + PlatformError, + PubSub, + Scope, + Stream, +} from "effect"; import { describe, expect, it, afterEach, vi } from "vitest"; import { createServer } from "./wsServer"; import WebSocket from "ws"; @@ -475,6 +485,7 @@ function deriveServerPathsSync(baseDir: string, devUrl: URL | undefined) { describe("WebSocket Server", () => { let server: Http.Server | null = null; let serverScope: Scope.Closeable | null = null; + let disposeServerRuntime: (() => Promise) | null = null; const connections: WebSocket[] = []; const tempDirs: string[] = []; @@ -521,6 +532,12 @@ describe("WebSocket Server", () => { options.providerRegistry ?? defaultProviderRegistryService, ); const openLayer = Layer.succeed(Open, options.open ?? defaultOpenService); + const nodeServicesLayer = NodeServices.layer; + const serverSettingsLayer = ServerSettingsService.layerTest(options.serverSettings); + const serverSettingsRuntimeLayer = serverSettingsLayer.pipe( + Layer.provideMerge(nodeServicesLayer), + ); + const analyticsLayer = AnalyticsService.layerTest; const serverConfigLayer = Layer.succeed(ServerConfig, { mode: "web", port: 0, @@ -536,6 +553,11 @@ describe("WebSocket Server", () => { logWebSocketEvents: options.logWebSocketEvents ?? Boolean(options.devUrl), } satisfies ServerConfigShape); const infrastructureLayer = providerLayer.pipe(Layer.provideMerge(persistenceLayer)); + const providerRuntimeLayer = infrastructureLayer.pipe( + Layer.provideMerge(serverConfigLayer), + Layer.provideMerge(serverSettingsRuntimeLayer), + Layer.provideMerge(analyticsLayer), + ); const runtimeOverrides = Layer.mergeAll( options.gitManager ? Layer.succeed(GitManager, options.gitManager) : Layer.empty, options.gitCore @@ -548,41 +570,49 @@ describe("WebSocket Server", () => { const runtimeLayer = Layer.merge( Layer.merge( - makeServerRuntimeServicesLayer().pipe(Layer.provide(infrastructureLayer)), - infrastructureLayer, + makeServerRuntimeServicesLayer().pipe( + Layer.provideMerge(providerRuntimeLayer), + Layer.provideMerge(serverConfigLayer), + Layer.provideMerge(serverSettingsRuntimeLayer), + Layer.provideMerge(analyticsLayer), + Layer.provideMerge(nodeServicesLayer), + ), + Layer.mergeAll(providerRuntimeLayer, serverSettingsRuntimeLayer, analyticsLayer), ), runtimeOverrides, ); - const dependenciesLayer = Layer.empty.pipe( - Layer.provideMerge(runtimeLayer), - Layer.provideMerge(providerRegistryLayer), - Layer.provideMerge(openLayer), - Layer.provideMerge(ServerSettingsService.layerTest(options.serverSettings)), - Layer.provideMerge(serverConfigLayer), - Layer.provideMerge(AnalyticsService.layerTest), - Layer.provideMerge(NodeServices.layer), - ); - const runtimeServices = await Effect.runPromise( - Layer.build(dependenciesLayer).pipe(Scope.provide(scope)), + const dependenciesLayer = Layer.mergeAll( + runtimeLayer, + providerRegistryLayer, + openLayer, + serverConfigLayer, + nodeServicesLayer, ); - + const runtime = ManagedRuntime.make(dependenciesLayer); try { - const runtime = await Effect.runPromise( - createServer().pipe(Effect.provide(runtimeServices), Scope.provide(scope)), - ); + const httpServer = await runtime.runPromise(createServer().pipe(Scope.provide(scope))); + disposeServerRuntime = () => runtime.dispose(); serverScope = scope; - return runtime; + return httpServer; } catch (error) { + await runtime.dispose(); await Effect.runPromise(Scope.close(scope, Exit.void)); throw error; } } async function closeTestServer() { - if (!serverScope) return; + if (!serverScope && !disposeServerRuntime) return; const scope = serverScope; + const disposeRuntime = disposeServerRuntime; serverScope = null; - await Effect.runPromise(Scope.close(scope, Exit.void)); + disposeServerRuntime = null; + if (scope) { + await Effect.runPromise(Scope.close(scope, Exit.void)); + } + if (disposeRuntime) { + await disposeRuntime(); + } } afterEach(async () => { @@ -1255,6 +1285,32 @@ describe("WebSocket Server", () => { expect(response.error?.message).toContain("exceeds current turn count"); }); + it("rejects project.create when the workspace root does not exist", async () => { + server = await createTestServer({ cwd: "/test" }); + const addr = server.address(); + const port = typeof addr === "object" && addr !== null ? addr.port : 0; + + const [ws] = await connectAndAwaitWelcome(port); + connections.push(ws); + + const missingWorkspaceRoot = path.join(makeTempDir("t3code-ws-project-missing-"), "missing"); + const response = await sendRequest(ws, ORCHESTRATION_WS_METHODS.dispatchCommand, { + type: "project.create", + commandId: "cmd-ws-project-create-missing", + projectId: "project-missing", + title: "Missing Project", + workspaceRoot: missingWorkspaceRoot, + defaultModelSelection: { + provider: "codex", + model: "gpt-5-codex", + }, + createdAt: new Date().toISOString(), + }); + + expect(response.result).toBeUndefined(); + expect(response.error?.message).toContain("Workspace root does not exist:"); + }); + it("keeps orchestration domain push behavior for provider runtime events", async () => { const runtimeEventPubSub = Effect.runSync(PubSub.unbounded()); const emitRuntimeEvent = (event: ProviderRuntimeEvent) => { @@ -1665,6 +1721,50 @@ describe("WebSocket Server", () => { ); }); + it("invalidates workspace entry search cache after projects.writeFile", async () => { + const workspace = makeTempDir("t3code-ws-write-file-invalidate-"); + fs.mkdirSync(path.join(workspace, "src"), { recursive: true }); + fs.writeFileSync(path.join(workspace, "src", "existing.ts"), "export {};\n", "utf8"); + + server = await createTestServer({ cwd: "/test" }); + const addr = server.address(); + const port = typeof addr === "object" && addr !== null ? addr.port : 0; + + const [ws] = await connectAndAwaitWelcome(port); + connections.push(ws); + + const beforeWrite = await sendRequest(ws, WS_METHODS.projectsSearchEntries, { + cwd: workspace, + query: "rpc", + limit: 10, + }); + expect(beforeWrite.error).toBeUndefined(); + expect(beforeWrite.result).toEqual({ + entries: [], + truncated: false, + }); + + const writeResponse = await sendRequest(ws, WS_METHODS.projectsWriteFile, { + cwd: workspace, + relativePath: "plans/effect-rpc.md", + contents: "# Plan\n", + }); + expect(writeResponse.error).toBeUndefined(); + + const afterWrite = await sendRequest(ws, WS_METHODS.projectsSearchEntries, { + cwd: workspace, + query: "rpc", + limit: 10, + }); + expect(afterWrite.error).toBeUndefined(); + expect(afterWrite.result).toEqual({ + entries: expect.arrayContaining([ + expect.objectContaining({ path: "plans/effect-rpc.md", kind: "file" }), + ]), + truncated: false, + }); + }); + it("rejects projects.writeFile paths outside the workspace root", async () => { const workspace = makeTempDir("t3code-ws-write-file-reject-"); @@ -1683,7 +1783,7 @@ describe("WebSocket Server", () => { expect(response.result).toBeUndefined(); expect(response.error?.message).toContain( - "Workspace file path must stay within the project root.", + "Workspace file path must be relative to the project root: ../escape.md", ); expect(fs.existsSync(path.join(workspace, "..", "escape.md"))).toBe(false); }); diff --git a/apps/server/src/wsServer.ts b/apps/server/src/wsServer.ts index 25f8158926..b9fd7e58df 100644 --- a/apps/server/src/wsServer.ts +++ b/apps/server/src/wsServer.ts @@ -50,7 +50,6 @@ import { GitManager } from "./git/Services/GitManager.ts"; import { TerminalManager } from "./terminal/Services/Manager.ts"; import { Keybindings } from "./keybindings"; import { ServerSettingsService } from "./serverSettings"; -import { searchWorkspaceEntries } from "./workspaceEntries"; import { OrchestrationEngineService } from "./orchestration/Services/OrchestrationEngine"; import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery"; import { OrchestrationReactor } from "./orchestration/Services/OrchestrationReactor"; @@ -75,10 +74,13 @@ import { } from "./attachmentStore.ts"; import { parseBase64DataUrl } from "./imageMime.ts"; import { AnalyticsService } from "./telemetry/Services/AnalyticsService.ts"; -import { expandHomePath } from "./os-jank.ts"; import { makeServerPushBus } from "./wsServer/pushBus.ts"; import { makeServerReadiness } from "./wsServer/readiness.ts"; import { decodeJsonResult, formatSchemaError } from "@t3tools/shared/schemaJson"; +import { ProjectFaviconResolver } from "./project/Services/ProjectFaviconResolver.ts"; +import { WorkspaceEntries } from "./workspace/Services/WorkspaceEntries.ts"; +import { WorkspaceFileSystem } from "./workspace/Services/WorkspaceFileSystem.ts"; +import { WorkspacePaths } from "./workspace/Services/WorkspacePaths.ts"; /** * ServerShape - Service API for server lifecycle control. @@ -154,48 +156,6 @@ function websocketRawToString(raw: unknown): string | null { return null; } -function toPosixRelativePath(input: string): string { - return input.replaceAll("\\", "/"); -} - -function resolveWorkspaceWritePath(params: { - workspaceRoot: string; - relativePath: string; - path: Path.Path; -}): Effect.Effect<{ absolutePath: string; relativePath: string }, RouteRequestError> { - const normalizedInputPath = params.relativePath.trim(); - if (params.path.isAbsolute(normalizedInputPath)) { - return Effect.fail( - new RouteRequestError({ - message: "Workspace file path must be relative to the project root.", - }), - ); - } - - const absolutePath = params.path.resolve(params.workspaceRoot, normalizedInputPath); - const relativeToRoot = toPosixRelativePath( - params.path.relative(params.workspaceRoot, absolutePath), - ); - if ( - relativeToRoot.length === 0 || - relativeToRoot === "." || - relativeToRoot.startsWith("../") || - relativeToRoot === ".." || - params.path.isAbsolute(relativeToRoot) - ) { - return Effect.fail( - new RouteRequestError({ - message: "Workspace file path must stay within the project root.", - }), - ); - } - - return Effect.succeed({ - absolutePath, - relativePath: relativeToRoot, - }); -} - function stripRequestTag(body: T) { return Struct.omit(body, ["_tag"]); } @@ -218,6 +178,10 @@ export type ServerRuntimeServices = | TerminalManager | Keybindings | ServerSettingsService + | ProjectFaviconResolver + | WorkspaceEntries + | WorkspaceFileSystem + | WorkspacePaths | Open | AnalyticsService; @@ -263,6 +227,9 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< const serverSettingsManager = yield* ServerSettingsService; const providerRegistry = yield* ProviderRegistry; const git = yield* GitCore; + const workspaceEntries = yield* WorkspaceEntries; + const workspaceFileSystem = yield* WorkspaceFileSystem; + const workspacePaths = yield* WorkspacePaths; const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -312,35 +279,21 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< const normalizeDispatchCommand = Effect.fnUntraced(function* (input: { readonly command: ClientOrchestrationCommand; }) { - const normalizeProjectWorkspaceRoot = Effect.fnUntraced(function* (workspaceRoot: string) { - const normalizedWorkspaceRoot = path.resolve(yield* expandHomePath(workspaceRoot.trim())); - const workspaceStat = yield* fileSystem - .stat(normalizedWorkspaceRoot) - .pipe(Effect.catch(() => Effect.succeed(null))); - if (!workspaceStat) { - return yield* new RouteRequestError({ - message: `Project directory does not exist: ${normalizedWorkspaceRoot}`, - }); - } - if (workspaceStat.type !== "Directory") { - return yield* new RouteRequestError({ - message: `Project path is not a directory: ${normalizedWorkspaceRoot}`, - }); - } - return normalizedWorkspaceRoot; - }); - if (input.command.type === "project.create") { return { ...input.command, - workspaceRoot: yield* normalizeProjectWorkspaceRoot(input.command.workspaceRoot), + workspaceRoot: yield* workspacePaths + .normalizeWorkspaceRoot(input.command.workspaceRoot) + .pipe(Effect.mapError((cause) => new RouteRequestError({ message: cause.message }))), } satisfies OrchestrationCommand; } if (input.command.type === "project.meta.update" && input.command.workspaceRoot !== undefined) { return { ...input.command, - workspaceRoot: yield* normalizeProjectWorkspaceRoot(input.command.workspaceRoot), + workspaceRoot: yield* workspacePaths + .normalizeWorkspaceRoot(input.command.workspaceRoot) + .pipe(Effect.mapError((cause) => new RouteRequestError({ message: cause.message }))), } satisfies OrchestrationCommand; } @@ -437,7 +390,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< void runPromise( Effect.gen(function* () { const url = new URL(req.url ?? "/", `http://localhost:${port}`); - if (tryHandleProjectFaviconRequest(url, res)) { + if (yield* tryHandleProjectFaviconRequest(url, res)) { return; } @@ -768,41 +721,26 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< case WS_METHODS.projectsSearchEntries: { const body = stripRequestTag(request.body); - return yield* Effect.tryPromise({ - try: () => searchWorkspaceEntries(body), - catch: (cause) => - new RouteRequestError({ - message: `Failed to search workspace entries: ${String(cause)}`, - }), - }); + return yield* workspaceEntries.search(body).pipe( + Effect.mapError( + (cause) => + new RouteRequestError({ + message: `Failed to search workspace entries: ${cause.detail}`, + }), + ), + ); } case WS_METHODS.projectsWriteFile: { const body = stripRequestTag(request.body); - const target = yield* resolveWorkspaceWritePath({ - workspaceRoot: body.cwd, - relativePath: body.relativePath, - path, - }); - yield* fileSystem - .makeDirectory(path.dirname(target.absolutePath), { recursive: true }) - .pipe( - Effect.mapError( - (cause) => - new RouteRequestError({ - message: `Failed to prepare workspace path: ${String(cause)}`, - }), - ), - ); - yield* fileSystem.writeFileString(target.absolutePath, body.contents).pipe( + return yield* workspaceFileSystem.writeFile(body).pipe( Effect.mapError( (cause) => new RouteRequestError({ - message: `Failed to write workspace file: ${String(cause)}`, + message: `Failed to write workspace file: ${cause.message}`, }), ), ); - return { relativePath: target.relativePath }; } case WS_METHODS.shellOpenInEditor: { From dc85418d4fcca605bc0ab3804cac78853abe9189 Mon Sep 17 00:00:00 2001 From: sherlock Date: Tue, 31 Mar 2026 15:43:17 +0530 Subject: [PATCH 4/5] Address code review: security hardening, validation, error handling Security: - Reject symlinked favicon candidates via realPath resolution - Resolve real paths before workspace containment checks to prevent symlink-based directory traversal - Reject blank workspace roots before path.resolve falls back to CWD Correctness: - Check all path segments against IGNORED_DIRECTORY_NAMES, not just first - Only swallow ENOENT/ENOTDIR in nested readdir, propagate EACCES/EIO - Validate TCP port range 1-65535 in desktop resource polling - Use cause.detail for WorkspaceFileSystemError in writeFile error path --- apps/desktop/scripts/wait-for-resources.mjs | 2 +- .../project/Layers/ProjectFaviconResolver.ts | 19 ++++++++-- .../src/workspace/Layers/WorkspaceEntries.ts | 16 ++++++--- .../src/workspace/Layers/WorkspacePaths.ts | 35 +++++++++++++++++-- apps/server/src/wsServer.ts | 2 +- 5 files changed, 64 insertions(+), 10 deletions(-) diff --git a/apps/desktop/scripts/wait-for-resources.mjs b/apps/desktop/scripts/wait-for-resources.mjs index 2b0a60c5d9..8ab10d7412 100644 --- a/apps/desktop/scripts/wait-for-resources.mjs +++ b/apps/desktop/scripts/wait-for-resources.mjs @@ -80,7 +80,7 @@ export async function waitForResources({ tcpPort, connectTimeoutMs = 500, }) { - if (!Number.isInteger(tcpPort) || tcpPort <= 0) { + if (!Number.isInteger(tcpPort) || tcpPort <= 0 || tcpPort > 65_535) { throw new TypeError("waitForResources requires a positive integer tcpPort"); } diff --git a/apps/server/src/project/Layers/ProjectFaviconResolver.ts b/apps/server/src/project/Layers/ProjectFaviconResolver.ts index 3004a7a45c..061b6f5288 100644 --- a/apps/server/src/project/Layers/ProjectFaviconResolver.ts +++ b/apps/server/src/project/Layers/ProjectFaviconResolver.ts @@ -72,6 +72,12 @@ export const makeProjectFaviconResolver = Effect.gen(function* () { projectCwd: string, candidates: ReadonlyArray, ): Effect.fn.Return { + // Resolve the project root's real path once so symlink targets are compared + // against the canonical root, not the possibly-symlinked one. + const realProjectCwd = yield* fileSystem + .realPath(projectCwd) + .pipe(Effect.catch(() => Effect.succeed(projectCwd))); + for (const candidate of candidates) { if (!isPathWithinProject(projectCwd, candidate)) { continue; @@ -79,9 +85,18 @@ export const makeProjectFaviconResolver = Effect.gen(function* () { const stats = yield* fileSystem .stat(candidate) .pipe(Effect.catch(() => Effect.succeed(null))); - if (stats?.type === "File") { - return candidate; + if (stats?.type !== "File") { + continue; + } + // Resolve the candidate's real path to guard against symlinks that escape + // the project directory (e.g. favicon.svg -> /etc/passwd). + const realCandidate = yield* fileSystem + .realPath(candidate) + .pipe(Effect.catch(() => Effect.succeed(null))); + if (!realCandidate || !isPathWithinProject(realProjectCwd, realCandidate)) { + continue; } + return candidate; } return null; }); diff --git a/apps/server/src/workspace/Layers/WorkspaceEntries.ts b/apps/server/src/workspace/Layers/WorkspaceEntries.ts index c64960593c..d0dc1f2399 100644 --- a/apps/server/src/workspace/Layers/WorkspaceEntries.ts +++ b/apps/server/src/workspace/Layers/WorkspaceEntries.ts @@ -197,9 +197,9 @@ function insertRankedEntry( } function isPathInIgnoredDirectory(relativePath: string): boolean { - const firstSegment = relativePath.split("/")[0]; - if (!firstSegment) return false; - return IGNORED_DIRECTORY_NAMES.has(firstSegment); + return relativePath + .split("/") + .some((segment) => segment.length > 0 && IGNORED_DIRECTORY_NAMES.has(segment)); } function directoryAncestorsOf(relativePath: string): string[] { @@ -322,7 +322,15 @@ export const makeWorkspaceEntries = Effect.gen(function* () { }), }).pipe( Effect.catchIf( - () => relativeDir.length > 0, + (error) => { + if (relativeDir.length === 0) return false; + const cause = error.cause; + if (cause instanceof Error && "code" in cause) { + const code = (cause as NodeJS.ErrnoException).code; + return code === "ENOENT" || code === "ENOTDIR"; + } + return false; + }, () => Effect.succeed({ relativeDir, dirents: null }), ), ); diff --git a/apps/server/src/workspace/Layers/WorkspacePaths.ts b/apps/server/src/workspace/Layers/WorkspacePaths.ts index fa7a90cf07..7ae63df7fd 100644 --- a/apps/server/src/workspace/Layers/WorkspacePaths.ts +++ b/apps/server/src/workspace/Layers/WorkspacePaths.ts @@ -30,7 +30,14 @@ export const makeWorkspacePaths = Effect.gen(function* () { const normalizeWorkspaceRoot: WorkspacePathsShape["normalizeWorkspaceRoot"] = Effect.fn( "WorkspacePaths.normalizeWorkspaceRoot", )(function* (workspaceRoot) { - const normalizedWorkspaceRoot = path.resolve(expandHomePath(workspaceRoot.trim(), path)); + const trimmedRoot = workspaceRoot.trim(); + if (trimmedRoot.length === 0) { + return yield* new WorkspaceRootNotExistsError({ + workspaceRoot, + normalizedWorkspaceRoot: workspaceRoot, + }); + } + const normalizedWorkspaceRoot = path.resolve(expandHomePath(trimmedRoot, path)); const workspaceStat = yield* fileSystem .stat(normalizedWorkspaceRoot) .pipe(Effect.catch(() => Effect.succeed(null))); @@ -60,7 +67,31 @@ export const makeWorkspacePaths = Effect.gen(function* () { } const absolutePath = path.resolve(input.workspaceRoot, normalizedInputPath); - const relativeToRoot = toPosixRelativePath(path.relative(input.workspaceRoot, absolutePath)); + + // Resolve symlinks: find the deepest existing ancestor and resolve its + // real path so that symlinked intermediate directories cannot escape the + // workspace root. + const realRoot = yield* fileSystem + .realPath(input.workspaceRoot) + .pipe(Effect.catch(() => Effect.succeed(input.workspaceRoot))); + + let candidate = absolutePath; + let realCandidate: string | null = null; + while (candidate !== path.dirname(candidate)) { + const resolved = yield* fileSystem + .realPath(candidate) + .pipe(Effect.catch(() => Effect.succeed(null))); + if (resolved !== null) { + // Re-append the tail that didn't exist yet. + const tail = toPosixRelativePath(path.relative(candidate, absolutePath)); + realCandidate = tail ? path.resolve(resolved, tail) : resolved; + break; + } + candidate = path.dirname(candidate); + } + + const effectivePath = realCandidate ?? absolutePath; + const relativeToRoot = toPosixRelativePath(path.relative(realRoot, effectivePath)); if ( relativeToRoot.length === 0 || relativeToRoot === "." || diff --git a/apps/server/src/wsServer.ts b/apps/server/src/wsServer.ts index 00ac639999..cfc9d37cb3 100644 --- a/apps/server/src/wsServer.ts +++ b/apps/server/src/wsServer.ts @@ -749,7 +749,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< Effect.mapError( (cause) => new RouteRequestError({ - message: `Failed to write workspace file: ${cause.message}`, + message: `Failed to write workspace file: ${"detail" in cause ? cause.detail : cause.message}`, }), ), ); From 3241c6bb8fa1c190fb47ed91e5a9064196d8a3d7 Mon Sep 17 00:00:00 2001 From: sherlock Date: Tue, 31 Mar 2026 15:57:52 +0530 Subject: [PATCH 5/5] Fix WorkspaceEntries: dotfile queries, basename filtering, index budget - normalizeQuery: preserve leading dot for dotfile queries (.env) instead of stripping it with the path-prefix regex - isPathInIgnoredDirectory: only check ancestor segments, not the file basename (files named build/dist are legitimate) - Git index budget: cap directory entries to half the budget so derived directories can't starve file entries --- .../src/workspace/Layers/WorkspaceEntries.ts | 20 ++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/apps/server/src/workspace/Layers/WorkspaceEntries.ts b/apps/server/src/workspace/Layers/WorkspaceEntries.ts index d0dc1f2399..3dff60e9cc 100644 --- a/apps/server/src/workspace/Layers/WorkspaceEntries.ts +++ b/apps/server/src/workspace/Layers/WorkspaceEntries.ts @@ -76,7 +76,9 @@ function toSearchableWorkspaceEntry(entry: ProjectEntry): SearchableWorkspaceEnt function normalizeQuery(input: string): string { return input .trim() - .replace(/^[@./]+/, "") + .replace(/^@+/, "") + .replace(/^(?:\.\.?\/)+/, "") + .replace(/^\/+/, "") .toLowerCase(); } @@ -197,9 +199,8 @@ function insertRankedEntry( } function isPathInIgnoredDirectory(relativePath: string): boolean { - return relativePath - .split("/") - .some((segment) => segment.length > 0 && IGNORED_DIRECTORY_NAMES.has(segment)); + const segments = relativePath.split("/").filter((segment) => segment.length > 0); + return segments.slice(0, -1).some((segment) => IGNORED_DIRECTORY_NAMES.has(segment)); } function directoryAncestorsOf(relativePath: string): string[] { @@ -291,11 +292,16 @@ export const makeWorkspaceEntries = Effect.gen(function* () { ) .map(toSearchableWorkspaceEntry); - const entries = [...directoryEntries, ...fileEntries]; + const directoryBudget = Math.floor(WORKSPACE_INDEX_MAX_ENTRIES / 2); + const cappedDirectories = directoryEntries.slice(0, directoryBudget); + const fileBudget = WORKSPACE_INDEX_MAX_ENTRIES - cappedDirectories.length; + const cappedFiles = fileEntries.slice(0, fileBudget); + const entries = [...cappedDirectories, ...cappedFiles]; + const totalAvailable = directoryEntries.length + fileEntries.length; return { scannedAt: Date.now(), - entries: entries.slice(0, WORKSPACE_INDEX_MAX_ENTRIES), - truncated: listedFiles.truncated || entries.length > WORKSPACE_INDEX_MAX_ENTRIES, + entries, + truncated: listedFiles.truncated || totalAvailable > WORKSPACE_INDEX_MAX_ENTRIES, }; }, );